├── .gitignore ├── HappyAPI.svg ├── LICENSE ├── README.md ├── build └── build.clj ├── clay.edn ├── deps.edn ├── dev └── happyapi │ └── gen │ └── google │ ├── beaver.clj │ ├── lion.clj │ ├── monkey.clj │ └── raven.clj ├── docs ├── DESIGN.md ├── favicon.ico ├── happy.notebook.youtube_clojuretv.html ├── happy.notebook.youtube_clojuretv_files │ ├── html-default0.js │ ├── html-default1.js │ ├── katex2.js │ ├── vega3.js │ ├── vega4.js │ └── vega5.js └── index.md ├── notebooks └── happy │ └── notebook │ ├── ocr.clj │ ├── sheets.clj │ └── youtube_clojuretv.clj ├── pom.xml ├── resources └── favicon.ico ├── src └── happyapi │ ├── apikey │ └── client.clj │ ├── deps.clj │ ├── middleware.clj │ ├── oauth2 │ ├── auth.clj │ ├── capture_redirect.clj │ ├── client.clj │ └── credentials.clj │ ├── providers │ ├── amazon.clj │ ├── github.clj │ ├── google.clj │ └── twitter.clj │ └── setup.clj ├── test ├── happyapi.edn └── happyapi │ ├── apikey │ └── client_test.clj │ ├── deps_test.clj │ ├── gen │ └── google │ │ ├── beaver_test.clj │ │ ├── lion_test.clj │ │ └── monkey_test.clj │ ├── middleware_test.clj │ ├── oauth2 │ ├── auth_test.clj │ ├── capture_redirect_test.clj │ └── client_test.clj │ ├── providers │ ├── github_test.clj │ ├── google_test.clj │ └── twitter_test.clj │ └── setup_test.clj └── tests.edn /.gitignore: -------------------------------------------------------------------------------- 1 | /happyapi.edn 2 | /tokens 3 | *.json 4 | /target 5 | /classes 6 | /checkouts 7 | pom.xml.asc 8 | *.jar 9 | *.class 10 | /.nrepl-port 11 | /.cpcache 12 | -------------------------------------------------------------------------------- /HappyAPI.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HappyAPI 2 | 3 | HappyAPI logo 4 | 5 | A Clojure library for OAuth2. 6 | A unified approach for interacting with web APIs instead of relying on custom clients per API. 7 | Creates simple, handy and useful API clients. 8 | 9 | 10 | | | Happy | 11 | |--------|---------------------------------------------------------------------------------------------------------| 12 | | Simple | use and compose the parts you like, with a crystallized default for convenience | 13 | | Handy | generates function signatures that are explorable in your IDE, bringing usage and documentation to hand | 14 | | Useful | a better way to call your favourite web service | 15 | 16 | 17 | ## Status 18 | 19 | Alpha: seeking feedback. 20 | 21 | ## Features 22 | 23 | * OAuth2 24 | * Code generation for endpoints 25 | * Pluggable dependencies for http and json 26 | * Middleware for flexibly constructing your own request stack 27 | * Sync and async 28 | 29 | ## Generated libraries 30 | 31 | * [happyapi.google](https://github.com/timothypratley/happyapi.google) for 32 | calling [Google APIs](https://developers.google.com/apis-explorer); gsheets, drive, bigquery, youtube, and so on. 33 | 34 | ## Rationale 35 | 36 | Large datasets and extensive functionality are available to us through web APIs, 37 | but calling these services is often a study in incidental complexity. 38 | Client libraries are over-specific ([death by specificity](https://www.youtube.com/watch?v=aSEQfqNYNAc) x 100). 39 | We can do better with maps. 40 | 41 | Many interesting webservices need OAuth2 to access them. 42 | HappyAPI is primarily a configurable and flexible OAuth2 client. 43 | 44 | The middleware pattern allows users flexibility to do things differently, and to reuse parts when accessing APIs from another provider. 45 | Middleware is preferable to protocols; the key abstraction is a function that makes a request. 46 | Other client concerns such as connection pools may be captured in a closure. 47 | 48 | Users have choices for http and json dependencies. 49 | Their client should respect those and use them instead of bringing in another dependency. 50 | 51 | HappyAPI emphasizes discoverability without objects by generating code and documentation from an API discovery document. 52 | HappyAPI generates functions (as code, not macros) for calling API endpoints, 53 | so that your editor can help you: 54 | 55 | 1. Autocomplete; See all available resources and methods 56 | 2. Help documentation; The function doc-strings contain a description, a link to online docs, and example inputs 57 | 3. Arity checking; Required parameters are function args 58 | 4. Informative exceptions on failure 59 | 60 | Having the shape of requests at hand saves tedious research. 61 | 62 | The discovery of GAPIs was inspired by [clj-gapi](https://github.com/ianbarber/clj-gapi). 63 | See Google API discovery: https://developers.google.com/discovery/v1/getting_started 64 | 65 | This approach should work well with other discovery documents, hopefully AWS will be added soon. 66 | 67 | ## Usage 68 | 69 | [![Clojars Project](https://img.shields.io/clojars/v/io.github.timothypratley/happyapi.svg)](https://clojars.org/io.github.timothypratley/happyapi) 70 | [![Clojars Project](https://img.shields.io/clojars/v/io.github.timothypratley/happyapi.google.svg)](https://clojars.org/io.github.timothypratley/happyapi.google) 71 | 72 | **Important: You'll also need `clj-http` and `cheshire`, or one of their alternatives, see [Dependencies](#dependencies) below for more details** 73 | 74 | ### Service providers 75 | 76 | Currently supported 77 | 78 | * Google 79 | * Amazon 80 | * GitHub 81 | * Twitter 82 | 83 | Adding a custom provider can often be done with configuration, 84 | if they follow common conventions. 85 | 86 | ### Service provider specific usage 87 | 88 | For Google APIs you can use the generated wrapper from the `happyapi.google` project. 89 | 90 | ```clojure 91 | (require '[happyapi.providers.google :as google]) 92 | (require '[happyapi.google.youtube-v3 :as youtube]) 93 | (google/api-request (youtube/channels-list "contentDetails,statistics" {:forUsername "ClojureTV"})) 94 | ``` 95 | 96 | The generated wrapper constructs a request for `happyapi.providers.google/api-request`. 97 | You can make custom, non-generated `api-requests` directly by passing the required arguments. 98 | 99 | ```clojure 100 | (require '[happyapi.providers.google :as google]) 101 | (google/setup! {:client_id "XYZ" 102 | :client_secret (System/getenv "GOOGLE_CLIENT_SECRET") 103 | :deps [:jetty :clj-http :cheshire]}) 104 | (google/api-request {:method :get 105 | :url "https://youtube.googleapis.com/youtube/v3/channels" 106 | :query-params {:part "contentDetails,statistics" 107 | :forUsername "ClojureTV"} 108 | :scopes ["https://www.googleapis.com/auth/youtube.readonly"]}) 109 | ``` 110 | 111 | **Keep your client_secret secure. Do not add it directly in your code.** 112 | Looking it up from the environment is a common way to avoid leaking client_secret to source control. 113 | 114 | If `setup!` has not been called, 115 | the first call to `api-request` will attempt to configure itself. 116 | 117 | ### Configuration 118 | 119 | When no configuration is provided, 120 | HappyAPI tries to read configuration from the environment variable `HAPPYAPI_CONFIG`, 121 | then from a file `happyapi.edn`, and then from a resource `happyapi.edn`. 122 | 123 | ```clojure 124 | {:google {:deps [:httpkit :cheshire] ;; see happyapi.deps for alternatives 125 | :fns {...} ;; if you prefer to provide your own dependencies 126 | :client_id "MY_ID" ;; oauth2 client_id of your app 127 | :client_secret "MY_SECRET" ;; oauth2 client_secret from your provider 128 | :apikey "MY_APIKEY" ;; only when not using oauth2 129 | :scopes [] ;; optional default scopes 130 | :keywordize-keys true}} ;; optional 131 | ``` 132 | 133 | **Keep your client_secret secure.** Add `happyapi.edn` to `.gitignore` to avoid adding it to source control. 134 | 135 | ### Redirect port 136 | 137 | When no port is specified (for example `:redirect_uri "http://localhost/redirect"`), HappyAPI listens on the default http port 80. 138 | 139 | Port 80 is a privileged port that requires root permissions, which may be problematic for some users. 140 | Google and GitHub allow the `redirect_uri` port to vary. 141 | Other providers do not. 142 | A random port is a natural choice. 143 | Configuring `:redirect_uri "http://localhost:0/redirect"` will listen on a random port. 144 | This is the default used for Google and GitHub if not configured otherwise. 145 | 146 | You can choose a port if you'd like. 147 | If you want to listen on port 8080, configure `:redirect_uri "http://localhost:8080/redirect"` 148 | This is the default used for Twitter if not configured otherwise. 149 | 150 | You must update your provider settings to match either the default, or your own `redirect_uri`. 151 | Providers require an exact match between the provider side settings and client config, 152 | so please check this carefully if you get an error. 153 | 154 | ### Instrumentation, logging, and metrics 155 | 156 | A common desire is to log or count every http request for telemetry. 157 | This can be done by passing a wrapped `request` function, var, or qualified symbol in setup. 158 | Symbols will be resolved. 159 | 160 | ```clojure 161 | (google/setup! {:client_id "XYZ" 162 | :client_secret (System/getenv "GOOGLE_CLIENT_SECRET") 163 | :deps [:httpkit :cheshire] 164 | :fns {:request my-wrapped-request-fn}}) 165 | ``` 166 | 167 | ### Custom service providers 168 | 169 | ```clojure 170 | (require '[happyapi.setup :as setup]) 171 | 172 | (def api-request 173 | (setup/make-client 174 | {:my-provider {:client_id "MY_CLIENT_ID" 175 | :client_secret (System/getenv "MY_CLIENT_SECRET") 176 | :deps [:jetty :clj-http :cheshire]}} 177 | :my-provider)) 178 | 179 | (api-request {:method :get 180 | :url "https://my.provider/endpoint" 181 | :query-params {:foo "bar"}}) 182 | ``` 183 | 184 | HappyAPI is highly configurable. 185 | If you require further customization, 186 | you can also construct a stack of middleware using the `happyapi.oauth2.client` namespace for authentication, 187 | and `happyapi.oauth2.middleware` for useful miscellaneous conveniences. 188 | 189 | ### Dependencies 190 | 191 | You need HTTP and JSON dependencies. 192 | HappyAPI avoids creating a direct dependency because there are many implementations to choose from. 193 | 194 | * http client (clj-http, clj-http.lite, httpkit) 195 | * json encoder/decoder (cheshire, jsonista, clojure.data.json, charred) 196 | * A web server to receive redirects (jetty, httpkit) 197 | 198 | To choose your dependencies, 199 | configure `:deps [:httpkit :cheshire]`, or `:deps [:clj-http :jetty :charred]`, 200 | or whichever combo you want to use. 201 | 202 | There are no defaults. If you can't decide which to use, then I suggest `[:httpkit :cheshire]` 203 | 204 | Valid keys are `#{:cheshire :clj-http.lite :jetty :clj-http :data.json :httpkit :jsonista :charred}` 205 | 206 | **Configuration of either `:deps` or `:fns` is required.** 207 | 208 | If you wish, pass an explicit function, var, or qualified symbol instead: 209 | 210 | ```clojure 211 | :fns {:request my-http-request 212 | :query-string 'my-ns/my-query-string 213 | :encode #'my-json-write 214 | :decode my-json-parse} 215 | ``` 216 | 217 | Or a combination of both: 218 | ```clojure 219 | :deps [:httpkit] 220 | :fns {:encode my-json-write 221 | :decode my-json-parse} 222 | ``` 223 | 224 | See `happyapi.deps` namespace for more information about dependency resolution. 225 | 226 | #### Choose a http client 227 | 228 | [![Clojars Project](https://img.shields.io/clojars/v/http-kit.svg)](https://clojars.org/http-kit) 229 | [![Clojars Project](https://img.shields.io/clojars/v/clj-http.svg)](https://clojars.org/clj-http) 230 | [![Clojars Project](https://img.shields.io/clojars/v/org.clj-commons/clj-http-lite.svg)](https://clojars.org/org.clj-commons/clj-http-lite) 231 | 232 | #### Choose a web server 233 | 234 | [![Clojars Project](https://img.shields.io/clojars/v/http-kit.svg)](https://clojars.org/http-kit) 235 | [![Clojars Project](https://img.shields.io/clojars/v/ring.svg)](https://clojars.org/ring) 236 | [![Clojars Project](https://img.shields.io/clojars/v/ring/ring-jetty-adapter.svg)](https://clojars.org/ring/ring-jetty-adapter) 237 | 238 | #### Choose a json encoder/decoder 239 | 240 | [![Clojars Project](https://img.shields.io/clojars/v/cheshire.svg)](https://clojars.org/cheshire) 241 | [![Clojars Project](https://img.shields.io/clojars/v/com.cnuernber/charred.svg)](https://clojars.org/com.cnuernber/charred) 242 | [![Clojars Project](https://img.shields.io/clojars/v/metosin/jsonista.svg)](https://clojars.org/metosin/jsonista) 243 | [org.clojure/data.json](https://github.com/clojure/data.json) 244 | 245 | ### Authorization 246 | 247 | To participate in OAuth2 you need to fetch and store tokens. 248 | 249 | To create an app in the Google Console, 250 | follow [Setting up OAuth 2.0](https://support.google.com/googleapi/answer/6158849?hl=en). 251 | 252 | When setting up the app credentials, add http://localhost:PORT/redirect to the authorized redirect URIs, 253 | and add yourself as a test user. 254 | 255 | PORT may be omitted for port 80. 256 | Listening on port 80 may not be possible for users that do not have root permissions. 257 | If you specify port 0, a random port will be used. 258 | The only known provider that supports random ports is Google. 259 | The default `redirect_uri` for Google specifies port 0 for random port selection. 260 | 261 | There are two methods for obtaining a token: 262 | 263 | * User redirects, which prompt a user to authorize your app. 264 | Download the `secret.json` from the [Google Console](https://console.cloud.google.com/). 265 | **Do not add this file to source control, keep it secured.** 266 | This method is suitable if you want users to grant your app access to their data. 267 | * Service account private key (suitable for server to server). 268 | [Create a Service account](https://developers.google.com/identity/protocols/oauth2/service-account) 269 | and download a `service.json` key file. 270 | **Do not add this file to source control, keep it secured.** 271 | This method is suitable for automated jobs. 272 | 273 | ### Credentials and token storage 274 | 275 | `happyapi.oauth2-credentials` stores tokens on disk in the `tokens` directory. 276 | 277 | **You should .gitignore the `tokens` directory to prevent them being stored in source control.** 278 | 279 | If you want to use HappyAPI in a web app, you should instead store and fetch tokens from your database. 280 | 281 | The `happyapi.oauth2.capture-redirect` namespace implements a listener to capture a code when the user is redirected from the oauth2 provider. 282 | Web applications should instead define a route to capture the code. 283 | 284 | ### Keywordization 285 | 286 | While keywordization is common practise in Clojure, 287 | it can be problematic when receiving arbitrary data because not all keys make valid keywords. 288 | HappyAPI follows the convention of JSON defaults to use string keys instead of keywords. 289 | 290 | You can pass `keywordize-keys true` as configuration if you prefer keyword keys. 291 | You can also pass `keywordize-keys (true|false)` to individual requests. 292 | 293 | My recommendation is to avoid keywordization. 294 | When you run into a non-keywordizable key it can be a real headache. 295 | 296 | ### Pagination 297 | 298 | HappyAPI retrieves all pages and join the results together. 299 | It also unwraps extraneous keys like `data` and `items`. 300 | It returns data, not responses. 301 | 302 | ### Retries 303 | 304 | HappyAPI leaves retries up to the consuming application. See the [`again`](https://github.com/liwp/again) library. 305 | 306 | ## Generating new wrappers 307 | 308 | See `dev` directory for `happyapi.gen` namespaces. 309 | 310 | ## Third Party Apps 311 | 312 | If you are implementing your own web service, you may find these useful: 313 | 314 | [Blog: What color is your auth?](https://www.kpassa.me/posts/happyapi-temporal/) 315 | 316 | [Youtube: What color is your auth?](https://www.youtube.com/watch?v=mmOh5fYkX7Q) 317 | 318 | ## Contributing 319 | 320 | Issues, pull requests, and suggestions are welcome. 321 | 322 | If you are looking for things to improve, see also [docs/DESIGN.md#future-work](docs/DESIGN.md#future-work) 323 | 324 | 325 | ## Testing 326 | 327 | To run the tests you need to download `secret.json` from the Google console and convert it to a `happyapi.edn` file. 328 | 329 | ``` 330 | clojure -M:dev:test 331 | ``` 332 | 333 | ## Building 334 | 335 | The api namespaces can be generated by running `happyapi.gen.google.lion/-main` 336 | 337 | ``` 338 | clojure -T:dev:build build/jar 339 | ``` 340 | 341 | ## Deploying 342 | 343 | ``` 344 | env CLOJARS_USERNAME=username CLOJARS_PASSWORD=clojars-token clojure -T:dev:build build/deploy 345 | ``` 346 | 347 | ## License 348 | 349 | Copyright © 2020 Timothy Pratley 350 | 351 | This program and the accompanying materials are made available under the 352 | terms of the Eclipse Public License 2.0 which is available at 353 | http://www.eclipse.org/legal/epl-2.0. 354 | 355 | This Source Code may also be made available under the following Secondary 356 | Licenses when the conditions for such availability set forth in the Eclipse 357 | Public License, v. 2.0 are satisfied: GNU General Public License as published by 358 | the Free Software Foundation, either version 2 of the License, or (at your 359 | option) any later version, with the GNU Classpath Exception which is available 360 | at https://www.gnu.org/software/classpath/license.html. 361 | -------------------------------------------------------------------------------- /build/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b] 3 | [deps-deploy.deps-deploy :as dd])) 4 | 5 | (def lib 'io.github.timothypratley/happyapi) 6 | (def version (format "1.0.%s-beta" (b/git-count-revs nil))) 7 | (def class-dir "target/classes") 8 | (def basis (b/create-basis {:project "deps.edn"})) 9 | (def jar-file (format "target/%s-%s.jar" (name lib) version)) 10 | 11 | (defn clean [_] 12 | (b/delete {:path "target"})) 13 | 14 | (defn jar [_] 15 | (b/write-pom {:class-dir class-dir 16 | :lib lib 17 | :version version 18 | :basis basis 19 | :src-dirs ["src"]}) 20 | (b/copy-dir {:src-dirs ["src" "resources"] 21 | :target-dir class-dir}) 22 | (b/jar {:class-dir class-dir 23 | :jar-file jar-file})) 24 | 25 | (defn deploy [_] 26 | (dd/deploy {:installer :remote 27 | :artifact jar-file 28 | :pom-file (b/pom-path {:lib lib :class-dir class-dir})})) 29 | -------------------------------------------------------------------------------- /clay.edn: -------------------------------------------------------------------------------- 1 | {:source-path ["notebooks/happyapi/notebook/youtube_clojuretv.clj"] 2 | :target-path "target/clay" 3 | :favicon "/favicon.ico" 4 | :show false 5 | :format [:quarto :html]} 6 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | 3 | :deps {org.clojure/clojure {:mvn/version "1.11.3"} 4 | buddy/buddy-sign {:mvn/version "3.5.351"} 5 | com.grzm/uri-template {:mvn/version "0.7.1"} 6 | ring/ring-core {:mvn/version "1.12.2"}} 7 | 8 | :aliases 9 | { 10 | ;; REPL with `clojure -A:dev:test:build:doc` 11 | ;; GENERATE happygapi2 with `clojure -M:dev` 12 | :dev {:extra-paths ["dev"] 13 | :main-opts ["-m" "happyapi.google.lion"] 14 | :extra-deps {meander/epsilon {:mvn/version "0.0.650"} 15 | ring/ring {:mvn/version "1.12.2"} 16 | org.slf4j/slf4j-nop {:mvn/version "2.0.13"} 17 | 18 | clj-http/clj-http {:mvn/version "3.13.0"} 19 | http-kit/http-kit {:mvn/version "2.8.0"} 20 | cheshire/cheshire {:mvn/version "5.13.0"} 21 | org.clojure/data.json {:mvn/version "2.5.0"} 22 | metosin/jsonista {:mvn/version "0.3.9"} 23 | 24 | ;; interactive visualization 25 | org.scicloj/kind-portal {:mvn/version "1-beta1"} 26 | djblue/portal {:mvn/version "0.56.0"}}} 27 | 28 | ;; BUILD with `clojure -T:dev:build build/jar` 29 | ;; DEPLOY with `env CLOJARS_USERNAME=username CLOJARS_PASSWORD=clojars-token clojure -T:dev:build build/deploy` 30 | :build {:extra-paths ["build"] 31 | :extra-deps {org.clojure/tools.build {:mvn/version "0.9.2"} 32 | slipset/deps-deploy {:mvn/version "0.2.2"}}} 33 | 34 | ;; LITERATE the documentation `clojure -X:dev:doc scicloj.clay.v2.api/make\!` 35 | :doc {:extra-paths ["notebooks"] 36 | :main-opts ["-m" "scicloj.clay.v2.api/make!"] 37 | :extra-deps {org.scicloj/kindly {:mvn/version "4-beta5"} 38 | org.scicloj/clay {:mvn/version "2-beta12"} 39 | io.github.timothypratley/happyapi.google {:local/root "../happyapi.google"}}} 40 | 41 | ;; Run TESTS with `clojure -M:dev:test` 42 | :test {:extra-paths ["test"] 43 | :main-opts ["-m" "kaocha.runner"] 44 | :extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}} 45 | 46 | ;; FORMAT the code with `clojure -M:cljfmt` 47 | :cljfmt {:main-opts ["-m" "cljfmt.main"] 48 | :extra-deps {cljfmt/cljfmt {:mvn/version "0.9.2"}}} 49 | 50 | ;; Update DEPENDENCIES with `clojure -M:outdated` 51 | :outdated {:deps {com.github.liquidz/antq {:mvn/version "2.8.1201"} 52 | org.slf4j/slf4j-nop {:mvn/version "2.0.13"}} 53 | :main-opts ["-m" "antq.core" ":check-clojure-tools" "true" ":upgrade" "true"]}}} 54 | -------------------------------------------------------------------------------- /dev/happyapi/gen/google/beaver.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.beaver 2 | "Builds code forms for calling the remote APIs." 3 | (:require [clojure.java.io :as io] 4 | [clojure.pprint :as pprint] 5 | [happyapi.gen.google.raven :as raven] 6 | [clojure.string :as str] 7 | [meander.strategy.epsilon :as s] 8 | [meander.epsilon :as m])) 9 | 10 | (def project-name "happyapi.google") 11 | (def out-dir (io/file ".." project-name "src" "happyapi" "google")) 12 | (def resource-dir (io/file ".." project-name "resources" "google")) 13 | 14 | (defn method-sym 15 | "Fully qualified by hyphenation for convenience" 16 | [{:strs [id]}] 17 | (->> (str/split id #"\.") 18 | (drop 1) 19 | (str/join \-) 20 | (symbol))) 21 | 22 | (defn summarize-schema 23 | "Given a json-schema of type definitions, 24 | and a request that is a $ref to one of those types, 25 | resolves $ref(s) to a depth of 5, 26 | discards the distracting information, 27 | and returns a pattern for constructing the required input." 28 | ([request schema] (summarize-schema request schema 1)) 29 | ([request schema depth] 30 | (m/rewrite request 31 | {"$ref" (m/pred string? ?ref)} 32 | ;;> 33 | ~(or (and (<= depth 5) 34 | (some-> (get schema (keyword ?ref)) 35 | (summarize-schema schema (inc depth)))) 36 | (symbol ?ref)) 37 | 38 | {"enum" [!enums ...]} 39 | ;;> 40 | [(m/app symbol !enums) ...] 41 | 42 | {"type" "array" 43 | "items" ?item} 44 | ;;> 45 | [~(summarize-schema ?item schema depth)] 46 | 47 | {"type" "object" 48 | "properties" (m/pred seq (m/seqable [!property !item] ...))} 49 | ;;> 50 | {& ([!property (m/app #(summarize-schema % schema depth) !item)] ...)} 51 | 52 | {"type" (m/pred string? ?type)} 53 | ;;> 54 | (m/app symbol ?type)))) 55 | 56 | (defn doc-string [{:as ?api :strs [schemas]} {:as ?method :strs [description request parameters parameterOrder]} ?request-sym] 57 | (str description \newline 58 | (raven/doc-link ?api ?method) 59 | 60 | (when (or request (seq parameterOrder)) 61 | (str \newline 62 | (when (seq parameterOrder) 63 | (str \newline 64 | (str/join \newline 65 | (for [p parameterOrder] 66 | (let [{:strs [type description]} (get parameters (keyword p))] 67 | (str p " <" type "> " description)))))) 68 | (when request 69 | (str \newline 70 | ?request-sym 71 | (if-let [s (summarize-schema request schemas)] 72 | (str ":" \newline (str/trim-newline (with-out-str (pprint/pprint s)))) 73 | " "))))) 74 | 75 | (when-let [opts (seq (for [[p {:strs [required type description]}] parameters 76 | ;; pageToken is handled by the wrap-paging middleware 77 | :when (and (not required) (not= p "pageToken"))] 78 | (str (name p) " <" type "> " description)))] 79 | (str \newline \newline 80 | "optional:" \newline 81 | (str/join \newline opts))))) 82 | 83 | (def request-sym 84 | (s/rewrite (m/or (m/pred nil? ?sym) 85 | {"$ref" (m/some (m/app symbol ?sym))} 86 | {"type" (m/some (m/app symbol ?sym))}) 87 | ?sym)) 88 | 89 | ;; TODO: location "query", location "path" 90 | ;; TODO: parameterOrder seems useful!!! 91 | 92 | (defn required-path-params [parameters] 93 | (into {} (for [[k {:strs [required location]}] parameters 94 | :when (and required (= location "path"))] 95 | [k (symbol k)]))) 96 | 97 | (defn required-query-params [parameters] 98 | (into {} (for [[k {:strs [required location]}] parameters 99 | :when (and required (= location "query"))] 100 | [k (symbol k)]))) 101 | 102 | (defn single-arity [{:as ?api :strs [baseUrl]} {:as ?method :strs [path httpMethod scopes request parameters parameterOrder]}] 103 | (let [?method-sym (method-sym ?method) 104 | ?request-sym (request-sym request) 105 | params (cond-> (mapv symbol parameterOrder) 106 | request (conj ?request-sym))] 107 | (list 'defn ?method-sym 108 | (doc-string ?api ?method ?request-sym) 109 | params 110 | (cond-> {:method (keyword (str/lower-case httpMethod)) 111 | :uri-template (str baseUrl path) 112 | :uri-template-args (required-path-params parameters) 113 | :query-params (required-query-params parameters) 114 | :scopes scopes} 115 | request (conj [:body ?request-sym]))))) 116 | 117 | (defn multi-arity [{:as ?api :strs [baseUrl]} {:as ?method :strs [path httpMethod scopes request parameters parameterOrder]}] 118 | (let [?method-sym (method-sym ?method) 119 | ?request-sym (request-sym request) 120 | params (cond-> (mapv symbol parameterOrder) 121 | request (conj ?request-sym))] 122 | (list 'defn ?method-sym 123 | (doc-string ?api ?method ?request-sym) 124 | (list params (list* ?method-sym (conj params nil))) 125 | (list (conj params 'optional) 126 | (cond-> {:method (keyword (str/lower-case httpMethod)) 127 | :uri-template (str baseUrl path) 128 | :uri-template-args (required-path-params parameters) 129 | :query-params (list 'merge (required-query-params parameters) 'optional) 130 | :scopes scopes} 131 | request (conj [:body ?request-sym])))))) 132 | 133 | (def extract-method 134 | "Given an api definition, and an api method definition, 135 | produces a defn form." 136 | (s/rewrite 137 | [{:as ?api} 138 | {"parameters" (m/seqable (m/or [(m/app symbol !required-parameters) {"required" true}] 139 | [(m/app symbol !optional-parameters) {}]) ...) 140 | :as ?method}] 141 | ;;> 142 | ~(if (seq !optional-parameters) 143 | (multi-arity ?api ?method) 144 | (single-arity ?api ?method)) 145 | 146 | ;; 147 | ?else ~(throw (ex-info "FAIL" {:input ?else})))) 148 | 149 | (def build-api-ns 150 | (s/rewrite 151 | (m/with [%resource {"methods" (m/seqable [_ !methods] ...) 152 | "resources" (m/seqable [_ %resource] ...)}] 153 | {"name" ?name 154 | "version" ?version 155 | "title" ?title 156 | "description" ?description 157 | "documentationLink" ?documentationLink 158 | "resources" {& (m/seqable [_ %resource] ...)} 159 | :as ?api}) 160 | ((ns ~(symbol (str project-name \. ?name "-" (str/replace ?version "." "-"))) 161 | ~(str ?title \newline 162 | ?description \newline 163 | "See: " (raven/maybe-redirected ?documentationLink))) 164 | . (m/app extract-method [?api !methods]) ...) 165 | ?else ~(throw (ex-info "FAIL" {:input ?else})))) 166 | -------------------------------------------------------------------------------- /dev/happyapi/gen/google/lion.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.lion 2 | "Writes to src/happygapi generated namespaces of function wrappers" 3 | (:require [clojure.edn :as edn] 4 | [happyapi.gen.google.beaver :as beaver] 5 | [happyapi.gen.google.monkey :as monkey] 6 | [happyapi.gen.google.raven :as raven] 7 | [clojure.java.io :as io] 8 | [clojure.pprint :as pprint] 9 | [clojure.string :as str])) 10 | 11 | (def discovery-failed& (atom #{})) 12 | 13 | (defn pprint-str [x] 14 | (-> (pprint/pprint x) 15 | (->> (pprint/with-pprint-dispatch pprint/code-dispatch)) 16 | (with-out-str) 17 | ;; docstrings shouldn't be escaped 18 | (str/replace "\\n" "\n"))) 19 | 20 | (defn ns-str [forms] 21 | (str/join \newline (map pprint-str forms))) 22 | 23 | (defn fetch-and-write [{:strs [name version discoveryRestUrl idx]}] 24 | (try 25 | ;; if we have it already just use it. 26 | (println (str "Building" (when idx (str " " idx " of " (count monkey/apis)))) name) 27 | (let [filename (str name "_" (str/replace version "." "_")) 28 | target (io/file beaver/out-dir (str filename ".clj")) 29 | api-file (io/file beaver/resource-dir (str filename ".edn")) 30 | api (if (.exists api-file) 31 | (edn/read-string (slurp api-file)) 32 | (doto (raven/get-json discoveryRestUrl) 33 | (->> (pr-str) (spit api-file))))] 34 | (->> (beaver/build-api-ns api) 35 | (ns-str) 36 | (spit target)) 37 | (println "Wrote" (str target)) 38 | :done) 39 | (catch Exception ex 40 | (swap! discovery-failed& conj discoveryRestUrl) 41 | (println (ex-message ex)) 42 | (println "To retry/resume, use" (pr-str `(write-one ~name)) "or" (pr-str `(write-all ~name))) 43 | ex))) 44 | 45 | (comment 46 | (fetch-and-write {:discoveryRestUrl "https://spanner.googleapis.com/$discovery/rest?version=v1"})) 47 | 48 | (defn report [] 49 | ;; failed discoveries 50 | (println "Failed discoveries:" \newline 51 | (str/join \newline @discovery-failed&)) 52 | 53 | ;; dead links 54 | (println "Dead links:" \newline 55 | (str/join \newline @raven/dead-link-cache&)) 56 | 57 | ;; redirects 58 | (println "Redirects:" \newline 59 | (str/join \newline @raven/redirect-cache&)) 60 | 61 | ;; unmatched patterns 62 | (println "Unmatched apis:" \newline 63 | (str/join \newline 64 | (for [[k v] @raven/pattern-cache& 65 | :when (= [:documentationLink] v)] 66 | k))) 67 | 68 | ;; frequencies 69 | (println "Frequencies:") 70 | (println (str/join \newline (reverse (sort-by val (frequencies (vals @raven/pattern-cache&)))))) 71 | 72 | ;; a table! or a chart? 73 | 74 | ;; disk cache?? 75 | 76 | ) 77 | 78 | (comment 79 | (report)) 80 | 81 | (defn write-all 82 | "Pass an api name to resume generation at a failure" 83 | ([] (write-all nil)) 84 | ([start] 85 | (.mkdirs beaver/out-dir) 86 | (.mkdirs beaver/resource-dir) 87 | (let [apis (->> (vals monkey/apis) 88 | (sort-by #(get % "name")) 89 | (map-indexed (fn [idx api] 90 | (assoc api "idx" idx)))) 91 | remaining (cond->> apis 92 | start (drop-while #(not= start (get % "name"))))] 93 | (run! fetch-and-write remaining)) 94 | (report) 95 | :done)) 96 | 97 | (defn write-one [api-name] 98 | (some-> (get monkey/apis api-name) 99 | (fetch-and-write))) 100 | 101 | (comment 102 | ;; spanner does not match because it has undocumented methods 103 | ;; TODO: should all method doc links be checked? 104 | (swap! raven/pattern-cache& dissoc "speech") 105 | (get monkey/apis "speech") 106 | (write-one "speech") 107 | (write-one "datalineage") 108 | ;; TODO: poly is no more 109 | (write-all "poly") 110 | (write-one "youtube")) 111 | 112 | (defn -main [& args] 113 | (write-all)) 114 | -------------------------------------------------------------------------------- /dev/happyapi/gen/google/monkey.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.monkey 2 | "Discovers Google API definitions." 3 | (:require [happyapi.gen.google.raven :as raven])) 4 | 5 | (def discovery-url "https://www.googleapis.com/discovery/v1/apis?preferred=true") 6 | 7 | (defn list-apis' 8 | "Returns a vector of preferred APIs with their discovery URL." 9 | [] 10 | (raven/get-json discovery-url)) 11 | 12 | (def list-apis (memoize list-apis')) 13 | 14 | (def apis (-> (list-apis) 15 | (->> (group-by #(get % "name"))) 16 | (update-vals first))) 17 | 18 | (comment 19 | (get apis "adexperiencereport")) 20 | -------------------------------------------------------------------------------- /dev/happyapi/gen/google/raven.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.raven 2 | "Tries to figure out the urls for api, resource, and method documentation." 3 | (:require [clojure.string :as str] 4 | [happyapi.deps :as deps] 5 | [happyapi.middleware :as middleware] 6 | [clj-http.client :as client])) 7 | 8 | (defonce pattern-cache& (atom {})) 9 | (defonce redirect-cache& (atom {})) 10 | (defonce dead-link-cache& (atom #{})) 11 | 12 | ;; fitness api is deprecated 13 | 14 | (def override-docs 15 | {"policyanalyzer" ["https://www.google.com" "https://cloud.google.com/policy-intelligence/docs/reference/policyanalyzer/rest/"]}) 16 | 17 | (def override-pattern 18 | {"iamcredentials" "https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken" 19 | "sql" [:documentationLink "mysql" "admin-api" "rest" :version :dot-path]}) 20 | 21 | 22 | (def http-request 23 | (-> client/request 24 | (middleware/wrap-informative-exceptions) 25 | (middleware/wrap-cookie-policy-standard))) 26 | 27 | (def json-request 28 | (-> http-request 29 | (middleware/wrap-json {:fns (deps/require-dep :cheshire)}) 30 | (middleware/wrap-extract-result))) 31 | 32 | (defn get-json [url] 33 | (json-request {:url url :method :get})) 34 | 35 | (defn can-get? [url] 36 | (try 37 | (middleware/success? (http-request {:method :get 38 | :url url})) 39 | (catch Exception ex 40 | false))) 41 | 42 | (defn format-url [m pattern] 43 | (str/join \/ (for [expr pattern] 44 | (if (keyword? expr) 45 | (get m (name expr)) 46 | expr)))) 47 | 48 | (defn try-pattern! 49 | "Returns a pattern if a url is successfully discovered." 50 | [m pattern] 51 | (and (can-get? (format-url m pattern)) 52 | pattern)) 53 | 54 | (defn verify-doc-path-pattern! 55 | "Documentation links are inconsistent 56 | (try to find out if the url schema pattern is documented). 57 | Just try them all and take the first one that doesn't 404. 58 | 59 | Examples: 60 | https://developers.google.com/abusive-experience-report/ 61 | https://developers.google.com/abusive-experience-report/v1/reference/rest/v1/sites/get 62 | 63 | https://developers.google.com/youtube/v3/docs/videos/list 64 | https://cloud.google.com/dataflow/docs/reference/rest/v1b3/projects.jobs/list 65 | https://developers.google.com/amp/cache/reference/acceleratedmobilepageurl/rest/v1/ampUrls/batchGet 66 | https://cloud.google.com/assured-workloads/access-approval/docs/reference/rest/v1/folders/getServiceAccount 67 | https://cloud.google.com/access-context-manager/docs/reference/rest/v1/accessPolicies/create 68 | 69 | https://developers.google.com/maps/documentation/addressvalidation 70 | https://developers.google.com/maps/documentation/address-validation/reference/rest/v1/TopLevel/validateAddress 71 | 72 | https://developers.google.com/youtube/reporting/v1/reports/ 73 | https://developers.google.com/youtube/reporting/v1/reference/rest/v1/jobs/create 74 | 75 | https://cloud.google.com/orgpolicy/docs/reference/rest/index.html 76 | https://cloud.google.com/resource-manager/docs/reference/orgpolicy/rest 77 | 78 | https://developers.google.com/blogger/docs/3.0/getting_started 79 | https://developers.google.com/blogger/docs/3.0/reference/blogs/get 80 | 81 | https://developers.google.com/google-apps/calendar/firstapp 82 | https://developers.google.com/calendar 83 | https://developers.google.com/calendar/api/v3/reference/calendarList/delete 84 | 85 | https://cloud.google.com/bigquery-transfer/ 86 | https://cloud.google.com/bigquery/ 87 | https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs/cancel 88 | 89 | https://cloud.google.com/learnmoreurl (404) 90 | https://cloud.google.com/assured-workloads/docs/ 91 | https://cloud.google.com/assured-workloads/docs/reference/rest/v1/organizations.locations.operations/get 92 | 93 | https://cloud.google.com/workstations 94 | https://cloud.google.com/workstations/docs/reference/rest/v1/projects.locations/list 95 | 96 | https://developers.google.com/workspace/events 97 | https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/create 98 | 99 | https://cloud.google.com/workload-manager/docs 100 | https://cloud.google.com/workload-manager/docs/reference/rest/v1/projects.locations.evaluations/create 101 | 102 | https://cloud.google.com/batch/ 103 | https://cloud.google.com/batch/docs/reference/rest/v1/projects.locations.jobs/get 104 | 105 | https://cloud.google.com/sql/docs 106 | https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1/connect/get 107 | 108 | https://cloud.google.com/bigquery 109 | https://cloud.google.com/bigquery/docs/reference/biglake/rest/v1/projects.locations.catalogs/list 110 | 111 | https://developers.google.com/people 112 | https://developers.google.com/people/api/rest/v1/contactGroups.members/modify 113 | 114 | https://developers.google.com/docs 115 | https://developers.google.com/docs/api/reference/rest/v1/documents/request 116 | 117 | TODO: versions all messed up. current is 4, discovery is 3, docs is 1beta 118 | https://developers.google.com/analytics 119 | https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/accounts/delete 120 | 121 | https://developers.google.com/civic-information 122 | https://developers.google.com/civic-information/docs/v2/elections/electionQuery 123 | 124 | https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.publishers.models/generateContent 125 | " 126 | [m] 127 | (or (try-pattern! m [:documentationLink :version "docs" :path]) 128 | (try-pattern! m [:documentationLink :version "docs" "reference" "rest" :path]) 129 | (try-pattern! m [:documentationLink :version "reference" "rest" :path]) 130 | (try-pattern! m [:documentationLink :version "reference" "rest" :version :path]) 131 | (try-pattern! m [:documentationLink "reference" :name "rest" :version :path]) 132 | (try-pattern! m [:documentationLink "reference" "rest" :version :path]) 133 | (try-pattern! m [:documentationLink "reference" "rest" :version :dot-path]) 134 | (try-pattern! m [:documentationLink "reference" "rest" :version "TopLevel" :path]) 135 | (try-pattern! m [:documentationLink "rest" :version "docs" :path]) 136 | ;; TODO: this is specific to one api... should it be an override instead? 137 | (try-pattern! m [:documentationLink "mysql" "admin-api" "rest" :version :dot-path]) 138 | ;; TODO: shouldn't this work for spanner? nope, spanner has undocumented methods 139 | (try-pattern! m [:documentationLink "docs" "reference" "rest" :version :dot-path]) 140 | (try-pattern! m [:documentationLink "docs" "reference" :name "rest" :version :dot-path]) 141 | (try-pattern! m [:documentationLink "docs" :version :path]) 142 | (try-pattern! m [:documentationLink "api" "rest" :version :dot-path]) 143 | (try-pattern! m [:documentationLink "api" "reference" "rest" :version :path]) 144 | (try-pattern! m [:documentationLink :version :dot-path]) 145 | (try-pattern! m [:documentationLink-1 "reference" "rest" :version :dot-path]) 146 | [:documentationLink])) 147 | 148 | (defn pattern-for 149 | "Returns the doc link pattern for an api, will update a cache if not found." 150 | [m] 151 | (let [api-name (:name m)] 152 | (or (get @pattern-cache& api-name) 153 | (doto (verify-doc-path-pattern! m) 154 | (->> (swap! pattern-cache& assoc api-name)))))) 155 | 156 | (defn doc-path 157 | "Google doc pages mostly follow a naming convention that aligns with a method id, 158 | but method ids start with the api-name, which is redundant in the documentationLink." 159 | [method-id] 160 | (when method-id 161 | (->> (str/split method-id #"\.") 162 | (rest) 163 | (str/join \/)))) 164 | 165 | (defn doc-dot-path 166 | "Google doc pages sometimes use resource.child/method instead of resource/child/method." 167 | [method-id] 168 | (when method-id 169 | (let [[_ & tail] (str/split method-id #"\.")] 170 | (str/join \/ [(str/join \. (butlast tail)) 171 | (last tail)])))) 172 | 173 | (defn maybe-redirected' 174 | "The documentationLink is sometimes redirected to a different url 175 | (I wish they would give us the correct url in the first place). 176 | Some, like https://cloud.google.com/learnmoreurl, are 404 dead links." 177 | [url] 178 | (try 179 | (let [response (http-request {:method :get 180 | :url url})] 181 | (let [{:keys [trace-redirects]} response 182 | redirect (last trace-redirects)] 183 | (if redirect 184 | (do (println "Redirect:" url "to" redirect) 185 | (swap! redirect-cache& assoc url redirect) 186 | redirect) 187 | url))) 188 | (catch Exception ex 189 | (println "Dead documentationLink:" (ex-message ex)) 190 | (swap! dead-link-cache& conj url) 191 | url))) 192 | 193 | (def maybe-redirected 194 | (memoize maybe-redirected')) 195 | 196 | (defn doc-link-maybe-override [{:strs [name documentationLink]}] 197 | (let [[prev override] (get override-docs name)] 198 | (if override 199 | (if (= prev documentationLink) 200 | override 201 | (do (println "EXPIRED OVERRIDE:" name) 202 | documentationLink)) 203 | documentationLink))) 204 | 205 | (defn remove-trailing-slash [s] 206 | (str/replace s #"/$" "")) 207 | 208 | (defn url-butlast 209 | "Removes the last path element of a url, if it has one." 210 | [url] 211 | (str/replace url #"^(https://.+)/[^/]+/?$" "$1")) 212 | 213 | (comment 214 | (url-butlast "https://www.google.com/") 215 | (url-butlast "https://www.google.com/foo/bar") 216 | (url-butlast "www.foo.bar/baz/booz")) 217 | 218 | (defn doc-link 219 | "Formats a direct link to resource or method documentation." 220 | [api method] 221 | (let [method-id (get method "id") 222 | doc (-> (doc-link-maybe-override api) 223 | (maybe-redirected) 224 | (remove-trailing-slash)) 225 | m (merge method 226 | (assoc api "documentationLink" doc) 227 | {"path" (doc-path method-id) 228 | ;; TODO: path and dot-path can overlap, causing an incorrect match 229 | "dot-path" (doc-dot-path method-id) 230 | "documentationLink-1" (url-butlast doc)}) 231 | pattern (pattern-for m)] 232 | (format-url m pattern))) 233 | 234 | (comment 235 | (swap! pattern-cache& dissoc "spanner")) 236 | -------------------------------------------------------------------------------- /docs/DESIGN.md: -------------------------------------------------------------------------------- 1 | # HappyAPI Design 2 | 3 | An Oauth2 library for Clojure. 4 | 5 | ## Context 6 | 7 | Calling an API is often a study in incidental complexity 8 | 9 | > Oh, what a tangled web we weave. 10 | > 11 | > -- Sir Walter Scott 12 | 13 | ![](https://svgsilh.com/svg/311050.svg) 14 | 15 | Making a request is easy - [`clj-http`](https://github.com/dakrone/clj-http). 16 | But there is a lot to think about: 17 | 18 | * Authentication and authorization 19 | * Specification 20 | * What resources are available and what parameters can be passed? 21 | * How do I navigate the specification? (Online docs? Class hierarchy? REPL?) 22 | * Request validation 23 | * Response comprehension 24 | * Paging 25 | * Handling failures and retries 26 | * Rate maximization keeping under a limit 27 | * Concurrency 28 | 29 | Despite this, web APIs are often described as easy. 30 | Companies like to pretend it's easy and users like to imagine that it's easy. 31 | Few people talk about the hard parts. 32 | This situation often ends with frustration and failure. 33 | 34 | ### Google APIs 35 | 36 | * Very wide. Everything from consumer cloud (docs, videos, email, ...) to professional cloud services (OCR, translate, databases, servers, ...) 37 | * 285 apis. (Think of a grid of 17x17 dots) 38 | * Lots of good stuff 39 | * Competitive pricing 40 | * Well defined, regular, documented 41 | * Underused it seems 42 | * The Java API is over specific [death by specificity](https://www.youtube.com/watch?v=aSEQfqNYNAc) but worse 43 | * Authentication model is necessarily complex 44 | 45 | ### Amazon APIs 46 | 47 | * Widely used by companies and startups 48 | * Typically low level services 49 | 50 | ### Other APIs 51 | 52 | * Often have unmaintained wrappers 53 | 54 | ## Guiding Principles 55 | 56 | * Small, simple components that form a complete solution. 57 | * Invite customization. 58 | 59 | ## The Hard Parts 60 | 61 | ### Auth 62 | 63 | * Basic username/password. 64 | Easy for users. 65 | Secure, **except** that it encourages users to store a plain text file containing credentials, which is risky. 66 | If those credentials are stolen, 67 | your account is compromised until password reset. 68 | Not widely offered/used. Not an option for GAPI. 69 | * API key. Easy and common, but often lacks fine-grained permissions. 70 | Github tokens can be limited in certain ways. For GAPI, the token is only useful for public APIs. 71 | It won't give you access to your data. 72 | Popular because it's really just basic auth, but with the ability to create multiple tokens. 73 | Keys should really go in the header, query parameters are secure but can appear in server logs as plain text. 74 | It depends on the provider whether you can do this, Google uses a query parameter for example. 75 | * OAuth2 tokens. Enables 3rd party applications to be permitted access to user data on a per-user and per-scope basis. 76 | Necessary for many Google APIs. Requires you to have an "app". 77 | Probably confusing to users who don't want to make an "app", just want their data. 78 | "Apps" have a secret token which is used to get access tokens. 79 | End users are redirected to Google to sign in and grant access, 80 | then redirected back to the "app" with the access token. Access tokens are end user specific and expire. 81 | Needs Google side configuration and a local http listening process. 82 | By the way, secrets often get saved in plain text files! 83 | Apps can be spoofed with secrets, so don't store them in plain text for a real app. 84 | By the way, tokens are often saved in plain text files! 85 | At least they expire, but pretty risky if you ask me. 86 | For GAPI you can create service accounts, which don't require interactive login (not really much different from tokens huh). 87 | 88 | ## The Solution: HappyGAPI 89 | 90 | A library! 91 | Generated code from the webservice description document (A big JSON file). 92 | Oauth2 authentication. 93 | 94 | Why do we need a new library? 95 | Existing libraries don't exist for Google APIs, and are not flexible enough to work with multiple providers. 96 | 97 | ### Specifications at hand: Generate code 98 | 99 | * Docstrings 100 | * Symbol resolution 101 | * Exploration, autocomplete 102 | * Parameter validation 103 | 104 | Makes consuming the API a pleasure! 105 | I can use my IDE features like "help" and "autocomplete" to quickly make requests with confidence that I got them right. 106 | 107 | There is value in having everything be data, and we can have both. 108 | 109 | ### Maintenance 110 | 111 | Automatically releasing schema changes would be nice to do in the future. 112 | 113 | ### Batteries included 114 | 115 | #### Auth 116 | 117 | Oauth2 118 | 119 | Credential lookup (looks for secret.json etc... is there a way to encourage encrypting credentials?) 120 | 121 | #### Paging 122 | 123 | #### Rate maximization 124 | 125 | There's already a good throttling library (but does it allow rate maximization)? 126 | 127 | #### Error Handling and Retries 128 | 129 | There's a retry library, is it enough? 130 | 131 | Exceptions vs Errors. I strongly encourage using Exceptions. 132 | Doing so frees us to expect data as the return value, rather than a response that requires interpretation. 133 | 134 | #### Response comprehension 135 | 136 | 1. Specification in documentation. 137 | 2. Throw errors. 138 | 3. Unwrap items. 139 | 4. Conjoin pages. 140 | 141 | ## Design Decisions 142 | 143 | ### Namespace Organization 144 | 145 | Originally I chose to follow the api organization as closely as possible, 146 | but upon reflection changed it to better collect resources: 147 | 148 | `happygapi.youtube.videos/list$` 149 | vs `happygapi.youtube/videos-list` 150 | vs `happygapi.youtube/videos$list` 151 | 152 | 1. The methods often overlap core functions like `list`, `get` etc. 153 | 2. Less requires are necessary when working with an API (YouTube has multiple resources). 154 | 3. Easier to search for functionality with autocomplete 155 | 156 | ### Mutability 157 | 158 | I originally chose to pass auth to every call, the proper functional choice. 159 | But upon reflection I changed it such that authentication happens as a side effect. 160 | This is more convenient, and if users don't want it they can assemble the middleware differently. 161 | 162 | ### Oauth2 163 | 164 | HappyAPI is now primarily an Oauth2 library, the code generation is in another project (happyapi.google). 165 | 166 | ### Default Scopes 167 | 168 | Previously there were default scopes, but now there are no default scopes. 169 | 170 | ### Typed Clojure annotations? Malli? 171 | 172 | These seem like they would be helpful (future work). 173 | 174 | ### Should the required parameters be positional args? 175 | 176 | Yes, that's how most other function works. 177 | Generated code uses positional args, but everything gets routed through a `request` function that takes a big map. 178 | So if you prefer that style, you can use that instead. 179 | 180 | ### Should record the GAPI version when publishing 181 | 182 | Future work. 183 | 184 | ## Ideas 185 | 186 | * Maybe API functions should create request maps? Yes that's what they do now 187 | * API calls aren't functions. Yes, but it's convenient to pretend they are 188 | * Actually clj-http solves many of these things, we just need some good defaults and a way to customize 189 | * Keep generated code in separate projects to avoid spam diffs 190 | * Control flow: Exceptions are actually good for distinguishing results and failures 191 | * Recommend exceptions 192 | * Reason I avoided them in the past is info swallowing, but informative exceptions solve that 193 | * They are good because we want the data, not the response representation 194 | * Pluggable dependencies sound great, but be careful. 195 | We'd like our dependencies to be stable, adding a dependency shouldn't change which implementation is used. 196 | 197 | ### Notes 198 | 199 | https://github.com/drone29a/clj-oauth 200 | https://github.com/sharetribe/aws-sig4 201 | 202 | Question: how to control arg checking (if at all?), maybe leave that up to users? 203 | Maybe follow Malli convention (or spec) 204 | Idea: 205 | 206 | ```clojure 207 | (defn strict! [] 208 | (alter-var-root #'api-request 209 | (fn [_prev] 210 | (wrap-check-args-or-something??)))) 211 | ``` 212 | 213 | We need a schema explorer experience - is this another case of summarize? 214 | 215 | Major providers are inconsistent. 216 | Providing config for urls is useful, users just need to add their id/secret. 217 | 218 | ## Future work 219 | 220 | Figure out a way to run tests (at least some of them) in CI. 221 | 222 | Make it ClojureScript compatible. 223 | 224 | Generated code currently requests more scopes than are really necessary :( I think you just need *any* of the scopes? not all? 225 | 226 | Numbers should be coerced to numbers based on the json schema for responses. 227 | 228 | Can we provide secure options for secret and token storage? 229 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timothypratley/happyapi/3109177d3276a0a65b63591eba7837ac0e50f379/docs/favicon.ico -------------------------------------------------------------------------------- /docs/happy.notebook.youtube_clojuretv_files/vega5.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("vega"),require("vega-lite")):"function"==typeof define&&define.amd?define(["vega","vega-lite"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).vegaEmbed=t(e.vega,e.vegaLite)}(this,(function(e,t){"use strict";function n(e){var t=Object.create(null);return e&&Object.keys(e).forEach((function(n){if("default"!==n){var r=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(t,n,r.get?r:{enumerable:!0,get:function(){return e[n]}})}})),t.default=e,Object.freeze(t)}var r,i=n(e),o=n(t),a=(r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])},r(e,t)},function(e,t){function n(){this.constructor=e}r(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}),s=Object.prototype.hasOwnProperty;function l(e,t){return s.call(e,t)}function c(e){if(Array.isArray(e)){for(var t=new Array(e.length),n=0;n=48&&t<=57))return!1;n++}return!0}function p(e){return-1===e.indexOf("/")&&-1===e.indexOf("~")?e:e.replace(/~/g,"~0").replace(/\//g,"~1")}function u(e){return e.replace(/~1/g,"/").replace(/~0/g,"~")}function d(e){if(void 0===e)return!0;if(e)if(Array.isArray(e)){for(var t=0,n=e.length;t0&&"constructor"==s[c-1]))throw new TypeError("JSON-Patch: modifying `__proto__` or `constructor/prototype` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README");if(n&&void 0===d&&(void 0===l[g]?d=s.slice(0,c).join("/"):c==p-1&&(d=t.path),void 0!==d&&m(t,0,e,d)),c++,Array.isArray(l)){if("-"===g)g=l.length;else{if(n&&!f(g))throw new v("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index","OPERATION_PATH_ILLEGAL_ARRAY_INDEX",o,t,e);f(g)&&(g=~~g)}if(c>=p){if(n&&"add"===t.op&&g>l.length)throw new v("The specified index MUST NOT be greater than the number of elements in the array","OPERATION_VALUE_OUT_OF_BOUNDS",o,t,e);if(!1===(a=y[t.op].call(t,l,g,e)).test)throw new v("Test operation failed","TEST_OPERATION_FAILED",o,t,e);return a}}else if(c>=p){if(!1===(a=b[t.op].call(t,l,g,e)).test)throw new v("Test operation failed","TEST_OPERATION_FAILED",o,t,e);return a}if(l=l[g],n&&c0)throw new v('Operation `path` property must start with "/"',"OPERATION_PATH_INVALID",t,e,n);if(("move"===e.op||"copy"===e.op)&&"string"!=typeof e.from)throw new v("Operation `from` property is not present (applicable in `move` and `copy` operations)","OPERATION_FROM_REQUIRED",t,e,n);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&void 0===e.value)throw new v("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_REQUIRED",t,e,n);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&d(e.value))throw new v("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED",t,e,n);if(n)if("add"==e.op){var i=e.path.split("/").length,o=r.split("/").length;if(i!==o+1&&i!==o)throw new v("Cannot perform an `add` operation at the desired path","OPERATION_PATH_CANNOT_ADD",t,e,n)}else if("replace"===e.op||"remove"===e.op||"_get"===e.op){if(e.path!==r)throw new v("Cannot perform the operation at a path that does not exist","OPERATION_PATH_UNRESOLVABLE",t,e,n)}else if("move"===e.op||"copy"===e.op){var a=I([{op:"_get",path:e.from,value:void 0}],n);if(a&&"OPERATION_PATH_UNRESOLVABLE"===a.name)throw new v("Cannot perform the operation from a path that does not exist","OPERATION_FROM_UNRESOLVABLE",t,e,n)}}function I(e,t,n){try{if(!Array.isArray(e))throw new v("Patch sequence must be an array","SEQUENCE_NOT_AN_ARRAY");if(t)A(h(t),h(e),n||!0);else{n=n||x;for(var r=0;r0&&(e.patches=[],e.callback&&e.callback(r)),r}function D(e,t,n,r,i){if(t!==e){"function"==typeof t.toJSON&&(t=t.toJSON());for(var o=c(t),a=c(e),s=!1,f=a.length-1;f>=0;f--){var u=e[g=a[f]];if(!l(t,g)||void 0===t[g]&&void 0!==u&&!1===Array.isArray(t))Array.isArray(e)===Array.isArray(t)?(i&&n.push({op:"test",path:r+"/"+p(g),value:h(u)}),n.push({op:"remove",path:r+"/"+p(g)}),s=!0):(i&&n.push({op:"test",path:r,value:e}),n.push({op:"replace",path:r,value:t}));else{var d=t[g];"object"==typeof u&&null!=u&&"object"==typeof d&&null!=d&&Array.isArray(u)===Array.isArray(d)?D(u,d,n,r+"/"+p(g),i):u!==d&&(i&&n.push({op:"test",path:r+"/"+p(g),value:h(u)}),n.push({op:"replace",path:r+"/"+p(g),value:h(d)}))}}if(s||o.length!=a.length)for(f=0;f0)return[m,n+c.join(",\n"+d),s].join("\n"+o)}return v}(e,"",0)},j=F(M);var z=B;function B(e){var t=this;if(t instanceof B||(t=new B),t.tail=null,t.head=null,t.length=0,e&&"function"==typeof e.forEach)e.forEach((function(e){t.push(e)}));else if(arguments.length>0)for(var n=0,r=arguments.length;n1)n=t;else{if(!this.head)throw new TypeError("Reduce of empty list with no initial value");r=this.head.next,n=this.head.value}for(var i=0;null!==r;i++)n=e(n,r.value,i),r=r.next;return n},B.prototype.reduceReverse=function(e,t){var n,r=this.tail;if(arguments.length>1)n=t;else{if(!this.tail)throw new TypeError("Reduce of empty list with no initial value");r=this.tail.prev,n=this.tail.value}for(var i=this.length-1;null!==r;i--)n=e(n,r.value,i),r=r.prev;return n},B.prototype.toArray=function(){for(var e=new Array(this.length),t=0,n=this.head;null!==n;t++)e[t]=n.value,n=n.next;return e},B.prototype.toArrayReverse=function(){for(var e=new Array(this.length),t=0,n=this.tail;null!==n;t++)e[t]=n.value,n=n.prev;return e},B.prototype.slice=function(e,t){(t=t||this.length)<0&&(t+=this.length),(e=e||0)<0&&(e+=this.length);var n=new B;if(tthis.length&&(t=this.length);for(var r=0,i=this.head;null!==i&&rthis.length&&(t=this.length);for(var r=this.length,i=this.tail;null!==i&&r>t;r--)i=i.prev;for(;null!==i&&r>e;r--,i=i.prev)n.push(i.value);return n},B.prototype.splice=function(e,t,...n){e>this.length&&(e=this.length-1),e<0&&(e=this.length+e);for(var r=0,i=this.head;null!==i&&r1;const ie=(e,t,n)=>{const r=e[te].get(t);if(r){const t=r.value;if(oe(e,t)){if(se(e,r),!e[J])return}else n&&(e[ne]&&(r.value.now=Date.now()),e[ee].unshiftNode(r));return t.value}},oe=(e,t)=>{if(!t||!t.maxAge&&!e[Q])return!1;const n=Date.now()-t.now;return t.maxAge?n>t.maxAge:e[Q]&&n>e[Q]},ae=e=>{if(e[q]>e[H])for(let t=e[ee].tail;e[q]>e[H]&&null!==t;){const n=t.prev;se(e,t),t=n}},se=(e,t)=>{if(t){const n=t.value;e[Z]&&e[Z](n.key,n.value),e[q]-=n.length,e[te].delete(n.key),e[ee].removeNode(t)}};class le{constructor(e,t,n,r,i){this.key=e,this.value=t,this.length=n,this.now=r,this.maxAge=i||0}}const ce=(e,t,n,r)=>{let i=n.value;oe(e,i)&&(se(e,n),e[J]||(i=void 0)),i&&t.call(r,i.value,i.key,e)};var he=class{constructor(e){if("number"==typeof e&&(e={max:e}),e||(e={}),e.max&&("number"!=typeof e.max||e.max<0))throw new TypeError("max must be a non-negative number");this[H]=e.max||1/0;const t=e.length||re;if(this[Y]="function"!=typeof t?re:t,this[J]=e.stale||!1,e.maxAge&&"number"!=typeof e.maxAge)throw new TypeError("maxAge must be a number");this[Q]=e.maxAge||0,this[Z]=e.dispose,this[K]=e.noDisposeOnSet||!1,this[ne]=e.updateAgeOnGet||!1,this.reset()}set max(e){if("number"!=typeof e||e<0)throw new TypeError("max must be a non-negative number");this[H]=e||1/0,ae(this)}get max(){return this[H]}set allowStale(e){this[J]=!!e}get allowStale(){return this[J]}set maxAge(e){if("number"!=typeof e)throw new TypeError("maxAge must be a non-negative number");this[Q]=e,ae(this)}get maxAge(){return this[Q]}set lengthCalculator(e){"function"!=typeof e&&(e=re),e!==this[Y]&&(this[Y]=e,this[q]=0,this[ee].forEach((e=>{e.length=this[Y](e.value,e.key),this[q]+=e.length}))),ae(this)}get lengthCalculator(){return this[Y]}get length(){return this[q]}get itemCount(){return this[ee].length}rforEach(e,t){t=t||this;for(let n=this[ee].tail;null!==n;){const r=n.prev;ce(this,e,n,t),n=r}}forEach(e,t){t=t||this;for(let n=this[ee].head;null!==n;){const r=n.next;ce(this,e,n,t),n=r}}keys(){return this[ee].toArray().map((e=>e.key))}values(){return this[ee].toArray().map((e=>e.value))}reset(){this[Z]&&this[ee]&&this[ee].length&&this[ee].forEach((e=>this[Z](e.key,e.value))),this[te]=new Map,this[ee]=new V,this[q]=0}dump(){return this[ee].map((e=>!oe(this,e)&&{k:e.key,v:e.value,e:e.now+(e.maxAge||0)})).toArray().filter((e=>e))}dumpLru(){return this[ee]}set(e,t,n){if((n=n||this[Q])&&"number"!=typeof n)throw new TypeError("maxAge must be a number");const r=n?Date.now():0,i=this[Y](t,e);if(this[te].has(e)){if(i>this[H])return se(this,this[te].get(e)),!1;const o=this[te].get(e).value;return this[Z]&&(this[K]||this[Z](e,o.value)),o.now=r,o.maxAge=n,o.value=t,this[q]+=i-o.length,o.length=i,this.get(e),ae(this),!0}const o=new le(e,t,i,r,n);return o.length>this[H]?(this[Z]&&this[Z](e,t),!1):(this[q]+=o.length,this[ee].unshift(o),this[te].set(e,this[ee].head),ae(this),!0)}has(e){if(!this[te].has(e))return!1;const t=this[te].get(e).value;return!oe(this,t)}get(e){return ie(this,e,!0)}peek(e){return ie(this,e,!1)}pop(){const e=this[ee].tail;return e?(se(this,e),e.value):null}del(e){se(this,this[te].get(e))}load(e){this.reset();const t=Date.now();for(let n=e.length-1;n>=0;n--){const r=e[n],i=r.e||0;if(0===i)this.set(r.k,r.v);else{const e=i-t;e>0&&this.set(r.k,r.v,e)}}}prune(){this[te].forEach(((e,t)=>ie(this,t,!1)))}};const fe=Object.freeze({loose:!0}),pe=Object.freeze({});var ue=e=>e?"object"!=typeof e?fe:e:pe,de={exports:{}};var ge={MAX_LENGTH:256,MAX_SAFE_COMPONENT_LENGTH:16,MAX_SAFE_BUILD_LENGTH:250,MAX_SAFE_INTEGER:Number.MAX_SAFE_INTEGER||9007199254740991,RELEASE_TYPES:["major","premajor","minor","preminor","patch","prepatch","prerelease"],SEMVER_SPEC_VERSION:"2.0.0",FLAG_INCLUDE_PRERELEASE:1,FLAG_LOOSE:2};var me="object"==typeof process&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...e)=>console.error("SEMVER",...e):()=>{};!function(e,t){const{MAX_SAFE_COMPONENT_LENGTH:n,MAX_SAFE_BUILD_LENGTH:r,MAX_LENGTH:i}=ge,o=me,a=(t=e.exports={}).re=[],s=t.safeRe=[],l=t.src=[],c=t.t={};let h=0;const f="[a-zA-Z0-9-]",p=[["\\s",1],["\\d",i],[f,r]],u=(e,t,n)=>{const r=(e=>{for(const[t,n]of p)e=e.split(`${t}*`).join(`${t}{0,${n}}`).split(`${t}+`).join(`${t}{1,${n}}`);return e})(t),i=h++;o(e,i,t),c[e]=i,l[i]=t,a[i]=new RegExp(t,n?"g":void 0),s[i]=new RegExp(r,n?"g":void 0)};u("NUMERICIDENTIFIER","0|[1-9]\\d*"),u("NUMERICIDENTIFIERLOOSE","\\d+"),u("NONNUMERICIDENTIFIER",`\\d*[a-zA-Z-]${f}*`),u("MAINVERSION",`(${l[c.NUMERICIDENTIFIER]})\\.(${l[c.NUMERICIDENTIFIER]})\\.(${l[c.NUMERICIDENTIFIER]})`),u("MAINVERSIONLOOSE",`(${l[c.NUMERICIDENTIFIERLOOSE]})\\.(${l[c.NUMERICIDENTIFIERLOOSE]})\\.(${l[c.NUMERICIDENTIFIERLOOSE]})`),u("PRERELEASEIDENTIFIER",`(?:${l[c.NUMERICIDENTIFIER]}|${l[c.NONNUMERICIDENTIFIER]})`),u("PRERELEASEIDENTIFIERLOOSE",`(?:${l[c.NUMERICIDENTIFIERLOOSE]}|${l[c.NONNUMERICIDENTIFIER]})`),u("PRERELEASE",`(?:-(${l[c.PRERELEASEIDENTIFIER]}(?:\\.${l[c.PRERELEASEIDENTIFIER]})*))`),u("PRERELEASELOOSE",`(?:-?(${l[c.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${l[c.PRERELEASEIDENTIFIERLOOSE]})*))`),u("BUILDIDENTIFIER",`${f}+`),u("BUILD",`(?:\\+(${l[c.BUILDIDENTIFIER]}(?:\\.${l[c.BUILDIDENTIFIER]})*))`),u("FULLPLAIN",`v?${l[c.MAINVERSION]}${l[c.PRERELEASE]}?${l[c.BUILD]}?`),u("FULL",`^${l[c.FULLPLAIN]}$`),u("LOOSEPLAIN",`[v=\\s]*${l[c.MAINVERSIONLOOSE]}${l[c.PRERELEASELOOSE]}?${l[c.BUILD]}?`),u("LOOSE",`^${l[c.LOOSEPLAIN]}$`),u("GTLT","((?:<|>)?=?)"),u("XRANGEIDENTIFIERLOOSE",`${l[c.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`),u("XRANGEIDENTIFIER",`${l[c.NUMERICIDENTIFIER]}|x|X|\\*`),u("XRANGEPLAIN",`[v=\\s]*(${l[c.XRANGEIDENTIFIER]})(?:\\.(${l[c.XRANGEIDENTIFIER]})(?:\\.(${l[c.XRANGEIDENTIFIER]})(?:${l[c.PRERELEASE]})?${l[c.BUILD]}?)?)?`),u("XRANGEPLAINLOOSE",`[v=\\s]*(${l[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${l[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${l[c.XRANGEIDENTIFIERLOOSE]})(?:${l[c.PRERELEASELOOSE]})?${l[c.BUILD]}?)?)?`),u("XRANGE",`^${l[c.GTLT]}\\s*${l[c.XRANGEPLAIN]}$`),u("XRANGELOOSE",`^${l[c.GTLT]}\\s*${l[c.XRANGEPLAINLOOSE]}$`),u("COERCE",`(^|[^\\d])(\\d{1,${n}})(?:\\.(\\d{1,${n}}))?(?:\\.(\\d{1,${n}}))?(?:$|[^\\d])`),u("COERCERTL",l[c.COERCE],!0),u("LONETILDE","(?:~>?)"),u("TILDETRIM",`(\\s*)${l[c.LONETILDE]}\\s+`,!0),t.tildeTrimReplace="$1~",u("TILDE",`^${l[c.LONETILDE]}${l[c.XRANGEPLAIN]}$`),u("TILDELOOSE",`^${l[c.LONETILDE]}${l[c.XRANGEPLAINLOOSE]}$`),u("LONECARET","(?:\\^)"),u("CARETTRIM",`(\\s*)${l[c.LONECARET]}\\s+`,!0),t.caretTrimReplace="$1^",u("CARET",`^${l[c.LONECARET]}${l[c.XRANGEPLAIN]}$`),u("CARETLOOSE",`^${l[c.LONECARET]}${l[c.XRANGEPLAINLOOSE]}$`),u("COMPARATORLOOSE",`^${l[c.GTLT]}\\s*(${l[c.LOOSEPLAIN]})$|^$`),u("COMPARATOR",`^${l[c.GTLT]}\\s*(${l[c.FULLPLAIN]})$|^$`),u("COMPARATORTRIM",`(\\s*)${l[c.GTLT]}\\s*(${l[c.LOOSEPLAIN]}|${l[c.XRANGEPLAIN]})`,!0),t.comparatorTrimReplace="$1$2$3",u("HYPHENRANGE",`^\\s*(${l[c.XRANGEPLAIN]})\\s+-\\s+(${l[c.XRANGEPLAIN]})\\s*$`),u("HYPHENRANGELOOSE",`^\\s*(${l[c.XRANGEPLAINLOOSE]})\\s+-\\s+(${l[c.XRANGEPLAINLOOSE]})\\s*$`),u("STAR","(<|>)?=?\\s*\\*"),u("GTE0","^\\s*>=\\s*0\\.0\\.0\\s*$"),u("GTE0PRE","^\\s*>=\\s*0\\.0\\.0-0\\s*$")}(de,de.exports);var ve=de.exports;const Ee=/^[0-9]+$/,be=(e,t)=>{const n=Ee.test(e),r=Ee.test(t);return n&&r&&(e=+e,t=+t),e===t?0:n&&!r?-1:r&&!n?1:ebe(t,e)};const we=me,{MAX_LENGTH:Oe,MAX_SAFE_INTEGER:Ae}=ge,{safeRe:xe,t:Ie}=ve,Ne=ue,{compareIdentifiers:Se}=ye;var $e=class e{constructor(t,n){if(n=Ne(n),t instanceof e){if(t.loose===!!n.loose&&t.includePrerelease===!!n.includePrerelease)return t;t=t.version}else if("string"!=typeof t)throw new TypeError(`Invalid version. Must be a string. Got type "${typeof t}".`);if(t.length>Oe)throw new TypeError(`version is longer than ${Oe} characters`);we("SemVer",t,n),this.options=n,this.loose=!!n.loose,this.includePrerelease=!!n.includePrerelease;const r=t.trim().match(n.loose?xe[Ie.LOOSE]:xe[Ie.FULL]);if(!r)throw new TypeError(`Invalid Version: ${t}`);if(this.raw=t,this.major=+r[1],this.minor=+r[2],this.patch=+r[3],this.major>Ae||this.major<0)throw new TypeError("Invalid major version");if(this.minor>Ae||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>Ae||this.patch<0)throw new TypeError("Invalid patch version");r[4]?this.prerelease=r[4].split(".").map((e=>{if(/^[0-9]+$/.test(e)){const t=+e;if(t>=0&&t=0;)"number"==typeof this.prerelease[r]&&(this.prerelease[r]++,r=-2);if(-1===r){if(t===this.prerelease.join(".")&&!1===n)throw new Error("invalid increment argument: identifier already exists");this.prerelease.push(e)}}if(t){let r=[t,e];!1===n&&(r=[t]),0===Se(this.prerelease[0],t)?isNaN(this.prerelease[1])&&(this.prerelease=r):this.prerelease=r}break}default:throw new Error(`invalid increment argument: ${e}`)}return this.raw=this.format(),this.build.length&&(this.raw+=`+${this.build.join(".")}`),this}};const Le=$e;var Re=(e,t,n)=>new Le(e,n).compare(new Le(t,n));const Te=Re;const De=Re;const Ce=Re;const Fe=Re;const ke=Re;const Pe=Re;const _e=(e,t,n)=>0===Te(e,t,n),Me=(e,t,n)=>0!==De(e,t,n),je=(e,t,n)=>Ce(e,t,n)>0,ze=(e,t,n)=>Fe(e,t,n)>=0,Be=(e,t,n)=>ke(e,t,n)<0,Ue=(e,t,n)=>Pe(e,t,n)<=0;var Ge,We,Xe,Ve,He=(e,t,n,r)=>{switch(t){case"===":return"object"==typeof e&&(e=e.version),"object"==typeof n&&(n=n.version),e===n;case"!==":return"object"==typeof e&&(e=e.version),"object"==typeof n&&(n=n.version),e!==n;case"":case"=":case"==":return _e(e,n,r);case"!=":return Me(e,n,r);case">":return je(e,n,r);case">=":return ze(e,n,r);case"<":return Be(e,n,r);case"<=":return Ue(e,n,r);default:throw new TypeError(`Invalid operator: ${t}`)}};function qe(){if(Ve)return Xe;Ve=1;class e{constructor(t,i){if(i=n(i),t instanceof e)return t.loose===!!i.loose&&t.includePrerelease===!!i.includePrerelease?t:new e(t.raw,i);if(t instanceof r)return this.raw=t.value,this.set=[[t]],this.format(),this;if(this.options=i,this.loose=!!i.loose,this.includePrerelease=!!i.includePrerelease,this.raw=t.trim().split(/\s+/).join(" "),this.set=this.raw.split("||").map((e=>this.parseRange(e.trim()))).filter((e=>e.length)),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${this.raw}`);if(this.set.length>1){const e=this.set[0];if(this.set=this.set.filter((e=>!u(e[0]))),0===this.set.length)this.set=[e];else if(this.set.length>1)for(const e of this.set)if(1===e.length&&d(e[0])){this.set=[e];break}}this.format()}format(){return this.range=this.set.map((e=>e.join(" ").trim())).join("||").trim(),this.range}toString(){return this.range}parseRange(e){const n=((this.options.includePrerelease&&f)|(this.options.loose&&p))+":"+e,o=t.get(n);if(o)return o;const d=this.options.loose,g=d?a[s.HYPHENRANGELOOSE]:a[s.HYPHENRANGE];e=e.replace(g,N(this.options.includePrerelease)),i("hyphen replace",e),e=e.replace(a[s.COMPARATORTRIM],l),i("comparator trim",e),e=e.replace(a[s.TILDETRIM],c),i("tilde trim",e),e=e.replace(a[s.CARETTRIM],h),i("caret trim",e);let v=e.split(" ").map((e=>m(e,this.options))).join(" ").split(/\s+/).map((e=>I(e,this.options)));d&&(v=v.filter((e=>(i("loose invalid filter",e,this.options),!!e.match(a[s.COMPARATORLOOSE]))))),i("range list",v);const E=new Map,b=v.map((e=>new r(e,this.options)));for(const e of b){if(u(e))return[e];E.set(e.value,e)}E.size>1&&E.has("")&&E.delete("");const y=[...E.values()];return t.set(n,y),y}intersects(t,n){if(!(t instanceof e))throw new TypeError("a Range is required");return this.set.some((e=>g(e,n)&&t.set.some((t=>g(t,n)&&e.every((e=>t.every((t=>e.intersects(t,n)))))))))}test(e){if(!e)return!1;if("string"==typeof e)try{e=new o(e,this.options)}catch(e){return!1}for(let t=0;t")||!e.operator.startsWith(">"))&&(!this.operator.startsWith("<")||!e.operator.startsWith("<"))&&(this.semver.version!==e.semver.version||!this.operator.includes("=")||!e.operator.includes("="))&&!(o(this.semver,"<",e.semver,r)&&this.operator.startsWith(">")&&e.operator.startsWith("<"))&&!(o(this.semver,">",e.semver,r)&&this.operator.startsWith("<")&&e.operator.startsWith(">")))}}Ge=t;const n=ue,{safeRe:r,t:i}=ve,o=He,a=me,s=$e,l=qe();return Ge}(),i=me,o=$e,{safeRe:a,t:s,comparatorTrimReplace:l,tildeTrimReplace:c,caretTrimReplace:h}=ve,{FLAG_INCLUDE_PRERELEASE:f,FLAG_LOOSE:p}=ge,u=e=>"<0.0.0-0"===e.value,d=e=>""===e.value,g=(e,t)=>{let n=!0;const r=e.slice();let i=r.pop();for(;n&&r.length;)n=r.every((e=>i.intersects(e,t))),i=r.pop();return n},m=(e,t)=>(i("comp",e,t),e=y(e,t),i("caret",e),e=E(e,t),i("tildes",e),e=O(e,t),i("xrange",e),e=x(e,t),i("stars",e),e),v=e=>!e||"x"===e.toLowerCase()||"*"===e,E=(e,t)=>e.trim().split(/\s+/).map((e=>b(e,t))).join(" "),b=(e,t)=>{const n=t.loose?a[s.TILDELOOSE]:a[s.TILDE];return e.replace(n,((t,n,r,o,a)=>{let s;return i("tilde",e,t,n,r,o,a),v(n)?s="":v(r)?s=`>=${n}.0.0 <${+n+1}.0.0-0`:v(o)?s=`>=${n}.${r}.0 <${n}.${+r+1}.0-0`:a?(i("replaceTilde pr",a),s=`>=${n}.${r}.${o}-${a} <${n}.${+r+1}.0-0`):s=`>=${n}.${r}.${o} <${n}.${+r+1}.0-0`,i("tilde return",s),s}))},y=(e,t)=>e.trim().split(/\s+/).map((e=>w(e,t))).join(" "),w=(e,t)=>{i("caret",e,t);const n=t.loose?a[s.CARETLOOSE]:a[s.CARET],r=t.includePrerelease?"-0":"";return e.replace(n,((t,n,o,a,s)=>{let l;return i("caret",e,t,n,o,a,s),v(n)?l="":v(o)?l=`>=${n}.0.0${r} <${+n+1}.0.0-0`:v(a)?l="0"===n?`>=${n}.${o}.0${r} <${n}.${+o+1}.0-0`:`>=${n}.${o}.0${r} <${+n+1}.0.0-0`:s?(i("replaceCaret pr",s),l="0"===n?"0"===o?`>=${n}.${o}.${a}-${s} <${n}.${o}.${+a+1}-0`:`>=${n}.${o}.${a}-${s} <${n}.${+o+1}.0-0`:`>=${n}.${o}.${a}-${s} <${+n+1}.0.0-0`):(i("no pr"),l="0"===n?"0"===o?`>=${n}.${o}.${a}${r} <${n}.${o}.${+a+1}-0`:`>=${n}.${o}.${a}${r} <${n}.${+o+1}.0-0`:`>=${n}.${o}.${a} <${+n+1}.0.0-0`),i("caret return",l),l}))},O=(e,t)=>(i("replaceXRanges",e,t),e.split(/\s+/).map((e=>A(e,t))).join(" ")),A=(e,t)=>{e=e.trim();const n=t.loose?a[s.XRANGELOOSE]:a[s.XRANGE];return e.replace(n,((n,r,o,a,s,l)=>{i("xRange",e,n,r,o,a,s,l);const c=v(o),h=c||v(a),f=h||v(s),p=f;return"="===r&&p&&(r=""),l=t.includePrerelease?"-0":"",c?n=">"===r||"<"===r?"<0.0.0-0":"*":r&&p?(h&&(a=0),s=0,">"===r?(r=">=",h?(o=+o+1,a=0,s=0):(a=+a+1,s=0)):"<="===r&&(r="<",h?o=+o+1:a=+a+1),"<"===r&&(l="-0"),n=`${r+o}.${a}.${s}${l}`):h?n=`>=${o}.0.0${l} <${+o+1}.0.0-0`:f&&(n=`>=${o}.${a}.0${l} <${o}.${+a+1}.0-0`),i("xRange return",n),n}))},x=(e,t)=>(i("replaceStars",e,t),e.trim().replace(a[s.STAR],"")),I=(e,t)=>(i("replaceGTE0",e,t),e.trim().replace(a[t.includePrerelease?s.GTE0PRE:s.GTE0],"")),N=e=>(t,n,r,i,o,a,s,l,c,h,f,p,u)=>`${n=v(r)?"":v(i)?`>=${r}.0.0${e?"-0":""}`:v(o)?`>=${r}.${i}.0${e?"-0":""}`:a?`>=${n}`:`>=${n}${e?"-0":""}`} ${l=v(c)?"":v(h)?`<${+c+1}.0.0-0`:v(f)?`<${c}.${+h+1}.0-0`:p?`<=${c}.${h}.${f}-${p}`:e?`<${c}.${h}.${+f+1}-0`:`<=${l}`}`.trim(),S=(e,t,n)=>{for(let n=0;n0){const r=e[n].semver;if(r.major===t.major&&r.minor===t.minor&&r.patch===t.patch)return!0}return!1}return!0};return Xe}const Ye=qe();var Je=(e,t,n)=>{try{t=new Ye(t,n)}catch(e){return!1}return t.test(e)},Qe=F(Je);var Ze={NaN:NaN,E:Math.E,LN2:Math.LN2,LN10:Math.LN10,LOG2E:Math.LOG2E,LOG10E:Math.LOG10E,PI:Math.PI,SQRT1_2:Math.SQRT1_2,SQRT2:Math.SQRT2,MIN_VALUE:Number.MIN_VALUE,MAX_VALUE:Number.MAX_VALUE},Ke={"*":(e,t)=>e*t,"+":(e,t)=>e+t,"-":(e,t)=>e-t,"/":(e,t)=>e/t,"%":(e,t)=>e%t,">":(e,t)=>e>t,"<":(e,t)=>ee<=t,">=":(e,t)=>e>=t,"==":(e,t)=>e==t,"!=":(e,t)=>e!=t,"===":(e,t)=>e===t,"!==":(e,t)=>e!==t,"&":(e,t)=>e&t,"|":(e,t)=>e|t,"^":(e,t)=>e^t,"<<":(e,t)=>e<>":(e,t)=>e>>t,">>>":(e,t)=>e>>>t},et={"+":e=>+e,"-":e=>-e,"~":e=>~e,"!":e=>!e};const tt=Array.prototype.slice,nt=(e,t,n)=>{const r=n?n(t[0]):t[0];return r[e].apply(r,tt.call(t,1))};var rt={isNaN:Number.isNaN,isFinite:Number.isFinite,abs:Math.abs,acos:Math.acos,asin:Math.asin,atan:Math.atan,atan2:Math.atan2,ceil:Math.ceil,cos:Math.cos,exp:Math.exp,floor:Math.floor,log:Math.log,max:Math.max,min:Math.min,pow:Math.pow,random:Math.random,round:Math.round,sin:Math.sin,sqrt:Math.sqrt,tan:Math.tan,clamp:(e,t,n)=>Math.max(t,Math.min(n,e)),now:Date.now,utc:Date.UTC,datetime:(e,t,n,r,i,o,a)=>new Date(e,t||0,null!=n?n:1,r||0,i||0,o||0,a||0),date:e=>new Date(e).getDate(),day:e=>new Date(e).getDay(),year:e=>new Date(e).getFullYear(),month:e=>new Date(e).getMonth(),hours:e=>new Date(e).getHours(),minutes:e=>new Date(e).getMinutes(),seconds:e=>new Date(e).getSeconds(),milliseconds:e=>new Date(e).getMilliseconds(),time:e=>new Date(e).getTime(),timezoneoffset:e=>new Date(e).getTimezoneOffset(),utcdate:e=>new Date(e).getUTCDate(),utcday:e=>new Date(e).getUTCDay(),utcyear:e=>new Date(e).getUTCFullYear(),utcmonth:e=>new Date(e).getUTCMonth(),utchours:e=>new Date(e).getUTCHours(),utcminutes:e=>new Date(e).getUTCMinutes(),utcseconds:e=>new Date(e).getUTCSeconds(),utcmilliseconds:e=>new Date(e).getUTCMilliseconds(),length:e=>e.length,join:function(){return nt("join",arguments)},indexof:function(){return nt("indexOf",arguments)},lastindexof:function(){return nt("lastIndexOf",arguments)},slice:function(){return nt("slice",arguments)},reverse:e=>e.slice().reverse(),parseFloat:parseFloat,parseInt:parseInt,upper:e=>String(e).toUpperCase(),lower:e=>String(e).toLowerCase(),substring:function(){return nt("substring",arguments,String)},split:function(){return nt("split",arguments,String)},replace:function(){return nt("replace",arguments,String)},trim:e=>String(e).trim(),regexp:RegExp,test:(e,t)=>RegExp(e).test(t)};const it=["view","item","group","xy","x","y"],ot=new Set([Function,eval,setTimeout,setInterval]);"function"==typeof setImmediate&&ot.add(setImmediate);const at={Literal:(e,t)=>t.value,Identifier:(e,t)=>{const n=t.name;return e.memberDepth>0?n:"datum"===n?e.datum:"event"===n?e.event:"item"===n?e.item:Ze[n]||e.params["$"+n]},MemberExpression:(e,t)=>{const n=!t.computed,r=e(t.object);n&&(e.memberDepth+=1);const i=e(t.property);if(n&&(e.memberDepth-=1),!ot.has(r[i]))return r[i];console.error(`Prevented interpretation of member "${i}" which could lead to insecure code execution`)},CallExpression:(e,t)=>{const n=t.arguments;let r=t.callee.name;return r.startsWith("_")&&(r=r.slice(1)),"if"===r?e(n[0])?e(n[1]):e(n[2]):(e.fn[r]||rt[r]).apply(e.fn,n.map(e))},ArrayExpression:(e,t)=>t.elements.map(e),BinaryExpression:(e,t)=>Ke[t.operator](e(t.left),e(t.right)),UnaryExpression:(e,t)=>et[t.operator](e(t.argument)),ConditionalExpression:(e,t)=>e(t.test)?e(t.consequent):e(t.alternate),LogicalExpression:(e,t)=>"&&"===t.operator?e(t.left)&&e(t.right):e(t.left)||e(t.right),ObjectExpression:(e,t)=>t.properties.reduce(((t,n)=>{e.memberDepth+=1;const r=e(n.key);return e.memberDepth-=1,ot.has(e(n.value))?console.error(`Prevented interpretation of property "${r}" which could lead to insecure code execution`):t[r]=e(n.value),t}),{})};function st(e,t,n,r,i,o){const a=e=>at[e.type](a,e);return a.memberDepth=0,a.fn=Object.create(t),a.params=n,a.datum=r,a.event=i,a.item=o,it.forEach((e=>a.fn[e]=function(){return i.vega[e](...arguments)})),a(e)}var lt={operator(e,t){const n=t.ast,r=e.functions;return e=>st(n,r,e)},parameter(e,t){const n=t.ast,r=e.functions;return(e,t)=>st(n,r,t,e)},event(e,t){const n=t.ast,r=e.functions;return e=>st(n,r,void 0,void 0,e)},handler(e,t){const n=t.ast,r=e.functions;return(e,t)=>{const i=t.item&&t.item.datum;return st(n,r,e,i,t)}},encode(e,t){const{marktype:n,channels:r}=t,i=e.functions,o="group"===n||"image"===n||"rect"===n;return(e,t)=>{const a=e.datum;let s,l=0;for(const n in r)s=st(r[n].ast,i,t,a,void 0,e),e[n]!==s&&(e[n]=s,l=1);return"rule"!==n&&function(e,t,n){let r;t.x2&&(t.x?(n&&e.x>e.x2&&(r=e.x,e.x=e.x2,e.x2=r),e.width=e.x2-e.x):e.x=e.x2-(e.width||0)),t.xc&&(e.x=e.xc-(e.width||0)/2),t.y2&&(t.y?(n&&e.y>e.y2&&(r=e.y,e.y=e.y2,e.y2=r),e.height=e.y2-e.y):e.y=e.y2-(e.height||0)),t.yc&&(e.y=e.yc-(e.height||0)/2)}(e,r,o),l}}};function ct(e){const[t,n]=/schema\/([\w-]+)\/([\w\.\-]+)\.json$/g.exec(e).slice(1,3);return{library:t,version:n}}var ht="2.14.0";const ft="#fff",pt="#888",ut={background:"#333",view:{stroke:pt},title:{color:ft,subtitleColor:ft},style:{"guide-label":{fill:ft},"guide-title":{fill:ft}},axis:{domainColor:ft,gridColor:pt,tickColor:ft}},dt="#4572a7",gt={background:"#fff",arc:{fill:dt},area:{fill:dt},line:{stroke:dt,strokeWidth:2},path:{stroke:dt},rect:{fill:dt},shape:{stroke:dt},symbol:{fill:dt,strokeWidth:1.5,size:50},axis:{bandPosition:.5,grid:!0,gridColor:"#000000",gridOpacity:1,gridWidth:.5,labelPadding:10,tickSize:5,tickWidth:.5},axisBand:{grid:!1,tickExtra:!0},legend:{labelBaseline:"middle",labelFontSize:11,symbolSize:50,symbolType:"square"},range:{category:["#4572a7","#aa4643","#8aa453","#71598e","#4598ae","#d98445","#94aace","#d09393","#b9cc98","#a99cbc"]}},mt="#30a2da",vt="#cbcbcb",Et="#f0f0f0",bt="#333",yt={arc:{fill:mt},area:{fill:mt},axis:{domainColor:vt,grid:!0,gridColor:vt,gridWidth:1,labelColor:"#999",labelFontSize:10,titleColor:"#333",tickColor:vt,tickSize:10,titleFontSize:14,titlePadding:10,labelPadding:4},axisBand:{grid:!1},background:Et,group:{fill:Et},legend:{labelColor:bt,labelFontSize:11,padding:1,symbolSize:30,symbolType:"square",titleColor:bt,titleFontSize:14,titlePadding:10},line:{stroke:mt,strokeWidth:2},path:{stroke:mt,strokeWidth:.5},rect:{fill:mt},range:{category:["#30a2da","#fc4f30","#e5ae38","#6d904f","#8b8b8b","#b96db8","#ff9e27","#56cc60","#52d2ca","#52689e","#545454","#9fe4f8"],diverging:["#cc0020","#e77866","#f6e7e1","#d6e8ed","#91bfd9","#1d78b5"],heatmap:["#d6e8ed","#cee0e5","#91bfd9","#549cc6","#1d78b5"]},point:{filled:!0,shape:"circle"},shape:{stroke:mt},bar:{binSpacing:2,fill:mt,stroke:null},title:{anchor:"start",fontSize:24,fontWeight:600,offset:20}},wt="#000",Ot={group:{fill:"#e5e5e5"},arc:{fill:wt},area:{fill:wt},line:{stroke:wt},path:{stroke:wt},rect:{fill:wt},shape:{stroke:wt},symbol:{fill:wt,size:40},axis:{domain:!1,grid:!0,gridColor:"#FFFFFF",gridOpacity:1,labelColor:"#7F7F7F",labelPadding:4,tickColor:"#7F7F7F",tickSize:5.67,titleFontSize:16,titleFontWeight:"normal"},legend:{labelBaseline:"middle",labelFontSize:11,symbolSize:40},range:{category:["#000000","#7F7F7F","#1A1A1A","#999999","#333333","#B0B0B0","#4D4D4D","#C9C9C9","#666666","#DCDCDC"]}},At="Benton Gothic, sans-serif",xt="#82c6df",It="Benton Gothic Bold, sans-serif",Nt="normal",St={"category-6":["#ec8431","#829eb1","#c89d29","#3580b1","#adc839","#ab7fb4"],"fire-7":["#fbf2c7","#f9e39c","#f8d36e","#f4bb6a","#e68a4f","#d15a40","#ab4232"],"fireandice-6":["#e68a4f","#f4bb6a","#f9e39c","#dadfe2","#a6b7c6","#849eae"],"ice-7":["#edefee","#dadfe2","#c4ccd2","#a6b7c6","#849eae","#607785","#47525d"]},$t={background:"#ffffff",title:{anchor:"start",color:"#000000",font:It,fontSize:22,fontWeight:"normal"},arc:{fill:xt},area:{fill:xt},line:{stroke:xt,strokeWidth:2},path:{stroke:xt},rect:{fill:xt},shape:{stroke:xt},symbol:{fill:xt,size:30},axis:{labelFont:At,labelFontSize:11.5,labelFontWeight:"normal",titleFont:It,titleFontSize:13,titleFontWeight:Nt},axisX:{labelAngle:0,labelPadding:4,tickSize:3},axisY:{labelBaseline:"middle",maxExtent:45,minExtent:45,tickSize:2,titleAlign:"left",titleAngle:0,titleX:-45,titleY:-11},legend:{labelFont:At,labelFontSize:11.5,symbolType:"square",titleFont:It,titleFontSize:13,titleFontWeight:Nt},range:{category:St["category-6"],diverging:St["fireandice-6"],heatmap:St["fire-7"],ordinal:St["fire-7"],ramp:St["fire-7"]}},Lt="#ab5787",Rt="#979797",Tt={background:"#f9f9f9",arc:{fill:Lt},area:{fill:Lt},line:{stroke:Lt},path:{stroke:Lt},rect:{fill:Lt},shape:{stroke:Lt},symbol:{fill:Lt,size:30},axis:{domainColor:Rt,domainWidth:.5,gridWidth:.2,labelColor:Rt,tickColor:Rt,tickWidth:.2,titleColor:Rt},axisBand:{grid:!1},axisX:{grid:!0,tickSize:10},axisY:{domain:!1,grid:!0,tickSize:0},legend:{labelFontSize:11,padding:1,symbolSize:30,symbolType:"square"},range:{category:["#ab5787","#51b2e5","#703c5c","#168dd9","#d190b6","#00609f","#d365ba","#154866","#666666","#c4c4c4"]}},Dt="#3e5c69",Ct={background:"#fff",arc:{fill:Dt},area:{fill:Dt},line:{stroke:Dt},path:{stroke:Dt},rect:{fill:Dt},shape:{stroke:Dt},symbol:{fill:Dt},axis:{domainWidth:.5,grid:!0,labelPadding:2,tickSize:5,tickWidth:.5,titleFontWeight:"normal"},axisBand:{grid:!1},axisX:{gridWidth:.2},axisY:{gridDash:[3],gridWidth:.4},legend:{labelFontSize:11,padding:1,symbolType:"square"},range:{category:["#3e5c69","#6793a6","#182429","#0570b0","#3690c0","#74a9cf","#a6bddb","#e2ddf2"]}},Ft="#1696d2",kt="#000000",Pt="Lato",_t="Lato",Mt={"main-colors":["#1696d2","#d2d2d2","#000000","#fdbf11","#ec008b","#55b748","#5c5859","#db2b27"],"shades-blue":["#CFE8F3","#A2D4EC","#73BFE2","#46ABDB","#1696D2","#12719E","#0A4C6A","#062635"],"shades-gray":["#F5F5F5","#ECECEC","#E3E3E3","#DCDBDB","#D2D2D2","#9D9D9D","#696969","#353535"],"shades-yellow":["#FFF2CF","#FCE39E","#FDD870","#FCCB41","#FDBF11","#E88E2D","#CA5800","#843215"],"shades-magenta":["#F5CBDF","#EB99C2","#E46AA7","#E54096","#EC008B","#AF1F6B","#761548","#351123"],"shades-green":["#DCEDD9","#BCDEB4","#98CF90","#78C26D","#55B748","#408941","#2C5C2D","#1A2E19"],"shades-black":["#D5D5D4","#ADABAC","#848081","#5C5859","#332D2F","#262223","#1A1717","#0E0C0D"],"shades-red":["#F8D5D4","#F1AAA9","#E9807D","#E25552","#DB2B27","#A4201D","#6E1614","#370B0A"],"one-group":["#1696d2","#000000"],"two-groups-cat-1":["#1696d2","#000000"],"two-groups-cat-2":["#1696d2","#fdbf11"],"two-groups-cat-3":["#1696d2","#db2b27"],"two-groups-seq":["#a2d4ec","#1696d2"],"three-groups-cat":["#1696d2","#fdbf11","#000000"],"three-groups-seq":["#a2d4ec","#1696d2","#0a4c6a"],"four-groups-cat-1":["#000000","#d2d2d2","#fdbf11","#1696d2"],"four-groups-cat-2":["#1696d2","#ec0008b","#fdbf11","#5c5859"],"four-groups-seq":["#cfe8f3","#73bf42","#1696d2","#0a4c6a"],"five-groups-cat-1":["#1696d2","#fdbf11","#d2d2d2","#ec008b","#000000"],"five-groups-cat-2":["#1696d2","#0a4c6a","#d2d2d2","#fdbf11","#332d2f"],"five-groups-seq":["#cfe8f3","#73bf42","#1696d2","#0a4c6a","#000000"],"six-groups-cat-1":["#1696d2","#ec008b","#fdbf11","#000000","#d2d2d2","#55b748"],"six-groups-cat-2":["#1696d2","#d2d2d2","#ec008b","#fdbf11","#332d2f","#0a4c6a"],"six-groups-seq":["#cfe8f3","#a2d4ec","#73bfe2","#46abdb","#1696d2","#12719e"],"diverging-colors":["#ca5800","#fdbf11","#fdd870","#fff2cf","#cfe8f3","#73bfe2","#1696d2","#0a4c6a"]},jt={background:"#FFFFFF",title:{anchor:"start",fontSize:18,font:Pt},axisX:{domain:!0,domainColor:kt,domainWidth:1,grid:!1,labelFontSize:12,labelFont:_t,labelAngle:0,tickColor:kt,tickSize:5,titleFontSize:12,titlePadding:10,titleFont:Pt},axisY:{domain:!1,domainWidth:1,grid:!0,gridColor:"#DEDDDD",gridWidth:1,labelFontSize:12,labelFont:_t,labelPadding:8,ticks:!1,titleFontSize:12,titlePadding:10,titleFont:Pt,titleAngle:0,titleY:-10,titleX:18},legend:{labelFontSize:12,labelFont:_t,symbolSize:100,titleFontSize:12,titlePadding:10,titleFont:Pt,orient:"right",offset:10},view:{stroke:"transparent"},range:{category:Mt["six-groups-cat-1"],diverging:Mt["diverging-colors"],heatmap:Mt["diverging-colors"],ordinal:Mt["six-groups-seq"],ramp:Mt["shades-blue"]},area:{fill:Ft},rect:{fill:Ft},line:{color:Ft,stroke:Ft,strokeWidth:5},trail:{color:Ft,stroke:Ft,strokeWidth:0,size:1},path:{stroke:Ft,strokeWidth:.5},point:{filled:!0},text:{font:"Lato",color:Ft,fontSize:11,align:"center",fontWeight:400,size:11},style:{bar:{fill:Ft,stroke:null}},arc:{fill:Ft},shape:{stroke:Ft},symbol:{fill:Ft,size:30}},zt="#3366CC",Bt="#ccc",Ut="Arial, sans-serif",Gt={arc:{fill:zt},area:{fill:zt},path:{stroke:zt},rect:{fill:zt},shape:{stroke:zt},symbol:{stroke:zt},circle:{fill:zt},background:"#fff",padding:{top:10,right:10,bottom:10,left:10},style:{"guide-label":{font:Ut,fontSize:12},"guide-title":{font:Ut,fontSize:12},"group-title":{font:Ut,fontSize:12}},title:{font:Ut,fontSize:14,fontWeight:"bold",dy:-3,anchor:"start"},axis:{gridColor:Bt,tickColor:Bt,domain:!1,grid:!0},range:{category:["#4285F4","#DB4437","#F4B400","#0F9D58","#AB47BC","#00ACC1","#FF7043","#9E9D24","#5C6BC0","#F06292","#00796B","#C2185B"],heatmap:["#c6dafc","#5e97f6","#2a56c6"]}},Wt=e=>e*(1/3+1),Xt=Wt(9),Vt=Wt(10),Ht=Wt(12),qt="Segoe UI",Yt="wf_standard-font, helvetica, arial, sans-serif",Jt="#252423",Qt="#605E5C",Zt="transparent",Kt="#118DFF",en="#DEEFFF",tn=[en,Kt],nn={view:{stroke:Zt},background:Zt,font:qt,header:{titleFont:Yt,titleFontSize:Ht,titleColor:Jt,labelFont:qt,labelFontSize:Vt,labelColor:Qt},axis:{ticks:!1,grid:!1,domain:!1,labelColor:Qt,labelFontSize:Xt,titleFont:Yt,titleColor:Jt,titleFontSize:Ht,titleFontWeight:"normal"},axisQuantitative:{tickCount:3,grid:!0,gridColor:"#C8C6C4",gridDash:[1,5],labelFlush:!1},axisBand:{tickExtra:!0},axisX:{labelPadding:5},axisY:{labelPadding:10},bar:{fill:Kt},line:{stroke:Kt,strokeWidth:3,strokeCap:"round",strokeJoin:"round"},text:{font:qt,fontSize:Xt,fill:Qt},arc:{fill:Kt},area:{fill:Kt,line:!0,opacity:.6},path:{stroke:Kt},rect:{fill:Kt},point:{fill:Kt,filled:!0,size:75},shape:{stroke:Kt},symbol:{fill:Kt,strokeWidth:1.5,size:50},legend:{titleFont:qt,titleFontWeight:"bold",titleColor:Qt,labelFont:qt,labelFontSize:Vt,labelColor:Qt,symbolType:"circle",symbolSize:75},range:{category:[Kt,"#12239E","#E66C37","#6B007B","#E044A7","#744EC2","#D9B300","#D64550"],diverging:tn,heatmap:tn,ordinal:[en,"#c7e4ff","#b0d9ff","#9aceff","#83c3ff","#6cb9ff","#55aeff","#3fa3ff","#2898ff",Kt]}},rn='IBM Plex Sans,system-ui,-apple-system,BlinkMacSystemFont,".sfnstext-regular",sans-serif',on=["#8a3ffc","#33b1ff","#007d79","#ff7eb6","#fa4d56","#fff1f1","#6fdc8c","#4589ff","#d12771","#d2a106","#08bdba","#bae6ff","#ba4e00","#d4bbff"],an=["#6929c4","#1192e8","#005d5d","#9f1853","#fa4d56","#570408","#198038","#002d9c","#ee538b","#b28600","#009d9a","#012749","#8a3800","#a56eff"];function sn({type:e,background:t}){const n="dark"===e?"#161616":"#ffffff",r="dark"===e?"#f4f4f4":"#161616",i="dark"===e?"#d4bbff":"#6929c4";return{background:t,arc:{fill:i},area:{fill:i},path:{stroke:i},rect:{fill:i},shape:{stroke:i},symbol:{stroke:i},circle:{fill:i},view:{fill:n,stroke:n},group:{fill:n},title:{color:r,anchor:"start",dy:-15,fontSize:16,font:rn,fontWeight:600},axis:{labelColor:r,labelFontSize:12,grid:!0,gridColor:"#525252",titleColor:r,labelAngle:0},style:{"guide-label":{font:rn,fill:r,fontWeight:400},"guide-title":{font:rn,fill:r,fontWeight:400}},range:{category:"dark"===e?on:an,diverging:["#750e13","#a2191f","#da1e28","#fa4d56","#ff8389","#ffb3b8","#ffd7d9","#fff1f1","#e5f6ff","#bae6ff","#82cfff","#33b1ff","#1192e8","#0072c3","#00539a","#003a6d"],heatmap:["#f6f2ff","#e8daff","#d4bbff","#be95ff","#a56eff","#8a3ffc","#6929c4","#491d8b","#31135e","#1c0f30"]}}}const ln=sn({type:"light",background:"#ffffff"}),cn=sn({type:"light",background:"#f4f4f4"}),hn=sn({type:"dark",background:"#262626"}),fn=sn({type:"dark",background:"#161616"}),pn=ht;var un=Object.freeze({__proto__:null,carbong10:cn,carbong100:fn,carbong90:hn,carbonwhite:ln,dark:ut,excel:gt,fivethirtyeight:yt,ggplot2:Ot,googlecharts:Gt,latimes:$t,powerbi:nn,quartz:Tt,urbaninstitute:jt,version:pn,vox:Ct});function dn(e,t,n){return e.fields=t||[],e.fname=n,e}function gn(e){return 1===e.length?mn(e[0]):vn(e)}const mn=e=>function(t){return t[e]},vn=e=>{const t=e.length;return function(n){for(let r=0;rr&&c(),s=r=i+1):"]"===o&&(s||En("Access path missing open bracket: "+e),s>0&&c(),s=0,r=i+1):i>r?c():r=i+1}return s&&En("Access path missing closing bracket: "+e),a&&En("Access path missing closing quote: "+e),i>r&&(i++,c()),t}(e);e=1===r.length?r[0]:e,dn((n&&n.get||gn)(r),[e],t||e)}("id"),dn((e=>e),[],"identity"),dn((()=>0),[],"zero"),dn((()=>1),[],"one"),dn((()=>!0),[],"true"),dn((()=>!1),[],"false");var bn=Array.isArray;function yn(e){return e===Object(e)}function wn(e){return wn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},wn(e)}function On(e){var t=function(e,t){if("object"!==wn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==wn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===wn(t)?t:String(t)}function An(e,t,n){return(t=On(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function xn(e,t){if(null==e)return{};var n,r,i=function(e,t){if(null==e)return{};var n,r,i={},o=Object.keys(e);for(r=0;r=0||(i[n]=e[n]);return i}(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(i[n]=e[n])}return i}const In=["title","image"];function Nn(e,t){return JSON.stringify(e,function(e){const t=[];return function(n,r){if("object"!=typeof r||null===r)return r;const i=t.indexOf(this)+1;return t.length=i,t.length>e?"[Object]":t.indexOf(r)>=0?"[Circular]":(t.push(r),r)}}(t))}var Sn="#vg-tooltip-element {\n visibility: hidden;\n padding: 8px;\n position: fixed;\n z-index: 1000;\n font-family: sans-serif;\n font-size: 11px;\n border-radius: 3px;\n box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);\n /* The default theme is the light theme. */\n background-color: rgba(255, 255, 255, 0.95);\n border: 1px solid #d9d9d9;\n color: black;\n}\n#vg-tooltip-element.visible {\n visibility: visible;\n}\n#vg-tooltip-element h2 {\n margin-top: 0;\n margin-bottom: 10px;\n font-size: 13px;\n}\n#vg-tooltip-element table {\n border-spacing: 0;\n}\n#vg-tooltip-element table tr {\n border: none;\n}\n#vg-tooltip-element table tr td {\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n padding-bottom: 2px;\n}\n#vg-tooltip-element table tr td.key {\n color: #808080;\n max-width: 150px;\n text-align: right;\n padding-right: 4px;\n}\n#vg-tooltip-element table tr td.value {\n display: block;\n max-width: 300px;\n max-height: 7em;\n text-align: left;\n}\n#vg-tooltip-element.dark-theme {\n background-color: rgba(32, 32, 32, 0.9);\n border: 1px solid #f5f5f5;\n color: white;\n}\n#vg-tooltip-element.dark-theme td.key {\n color: #bfbfbf;\n}\n";const $n="vg-tooltip-element",Ln={offsetX:10,offsetY:10,id:$n,styleId:"vega-tooltip-style",theme:"light",disableDefaultStyle:!1,sanitize:function(e){return String(e).replace(/&/g,"&").replace(/t("string"==typeof e?e:Nn(e,n)))).join(", ")}]`;if(yn(e)){let r="";const i=e,{title:o,image:a}=i,s=xn(i,In);o&&(r+=`

${t(o)}

`),a&&(r+=``);const l=Object.keys(s);if(l.length>0){r+="";for(const e of l){let i=s[e];void 0!==i&&(yn(i)&&(i=Nn(i,n)),r+=``)}r+="
${t(e)}:${t(i)}
"}return r||"{}"}return t(e)}};function Rn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Tn(e){for(var t=1;t0?n.insertBefore(e,n.childNodes[0]):n.appendChild(e)}}tooltipHandler(e,t,n,r){if(this.el=document.getElementById(this.options.id),!this.el){this.el=document.createElement("div"),this.el.setAttribute("id",this.options.id),this.el.classList.add("vg-tooltip");(document.fullscreenElement??document.body).appendChild(this.el)}if(null==r||""===r)return void this.el.classList.remove("visible",`${this.options.theme}-theme`);this.el.innerHTML=this.options.formatTooltip(r,this.options.sanitize,this.options.maxDepth),this.el.classList.add("visible",`${this.options.theme}-theme`);const{x:i,y:o}=function(e,t,n,r){let i=e.clientX+n;i+t.width>window.innerWidth&&(i=+e.clientX-n-t.width);let o=e.clientY+r;return o+t.height>window.innerHeight&&(o=+e.clientY-r-t.height),{x:i,y:o}}(t,this.el.getBoundingClientRect(),this.options.offsetX,this.options.offsetY);this.el.style.top=`${o}px`,this.el.style.left=`${i}px`}}var Cn='.vega-embed {\n position: relative;\n display: inline-block;\n box-sizing: border-box;\n}\n.vega-embed.has-actions {\n padding-right: 38px;\n}\n.vega-embed details:not([open]) > :not(summary) {\n display: none !important;\n}\n.vega-embed summary {\n list-style: none;\n position: absolute;\n top: 0;\n right: 0;\n padding: 6px;\n z-index: 1000;\n background: white;\n box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);\n color: #1b1e23;\n border: 1px solid #aaa;\n border-radius: 999px;\n opacity: 0.2;\n transition: opacity 0.4s ease-in;\n cursor: pointer;\n line-height: 0px;\n}\n.vega-embed summary::-webkit-details-marker {\n display: none;\n}\n.vega-embed summary:active {\n box-shadow: #aaa 0px 0px 0px 1px inset;\n}\n.vega-embed summary svg {\n width: 14px;\n height: 14px;\n}\n.vega-embed details[open] summary {\n opacity: 0.7;\n}\n.vega-embed:hover summary, .vega-embed:focus-within summary {\n opacity: 1 !important;\n transition: opacity 0.2s ease;\n}\n.vega-embed .vega-actions {\n position: absolute;\n z-index: 1001;\n top: 35px;\n right: -9px;\n display: flex;\n flex-direction: column;\n padding-bottom: 8px;\n padding-top: 8px;\n border-radius: 4px;\n box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2);\n border: 1px solid #d9d9d9;\n background: white;\n animation-duration: 0.15s;\n animation-name: scale-in;\n animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5);\n text-align: left;\n}\n.vega-embed .vega-actions a {\n padding: 8px 16px;\n font-family: sans-serif;\n font-size: 14px;\n font-weight: 600;\n white-space: nowrap;\n color: #434a56;\n text-decoration: none;\n}\n.vega-embed .vega-actions a:hover, .vega-embed .vega-actions a:focus {\n background-color: #f7f7f9;\n color: black;\n}\n.vega-embed .vega-actions::before, .vega-embed .vega-actions::after {\n content: "";\n display: inline-block;\n position: absolute;\n}\n.vega-embed .vega-actions::before {\n left: auto;\n right: 14px;\n top: -16px;\n border: 8px solid rgba(0, 0, 0, 0);\n border-bottom-color: #d9d9d9;\n}\n.vega-embed .vega-actions::after {\n left: auto;\n right: 15px;\n top: -14px;\n border: 7px solid rgba(0, 0, 0, 0);\n border-bottom-color: #fff;\n}\n.vega-embed .chart-wrapper.fit-x {\n width: 100%;\n}\n.vega-embed .chart-wrapper.fit-y {\n height: 100%;\n}\n\n.vega-embed-wrapper {\n max-width: 100%;\n overflow: auto;\n padding-right: 14px;\n}\n\n@keyframes scale-in {\n from {\n opacity: 0;\n transform: scale(0.6);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}\n';function Fn(e,...t){for(const n of t)kn(e,n);return e}function kn(t,n){for(const r of Object.keys(n))e.writeConfig(t,r,n[r],!0)}const Pn="6.22.2",_n=i;let Mn=o;const jn="undefined"!=typeof window?window:void 0;void 0===Mn&&jn?.vl?.compile&&(Mn=jn.vl);const zn={export:{svg:!0,png:!0},source:!0,compiled:!0,editor:!0},Bn={CLICK_TO_VIEW_ACTIONS:"Click to view actions",COMPILED_ACTION:"View Compiled Vega",EDITOR_ACTION:"Open in Vega Editor",PNG_ACTION:"Save as PNG",SOURCE_ACTION:"View Source",SVG_ACTION:"Save as SVG"},Un={vega:"Vega","vega-lite":"Vega-Lite"},Gn={vega:_n.version,"vega-lite":Mn?Mn.version:"not available"},Wn={vega:e=>e,"vega-lite":(e,t)=>Mn.compile(e,{config:t}).spec},Xn='\n\n \n \n \n',Vn="chart-wrapper";function Hn(e,t,n,r){const i=`${t}
`,o=`
${n}`,a=window.open("");a.document.write(i+e+o),a.document.title=`${Un[r]} JSON Source`}function qn(e){return(t=e)&&"load"in t?e:_n.loader(e);var t}async function Yn(t,n,r={}){let i,o;e.isString(n)?(o=qn(r.loader),i=JSON.parse(await o.load(n))):i=n;const a=function(t){const n=t.usermeta?.embedOptions??{};return e.isString(n.defaultStyle)&&(n.defaultStyle=!1),n}(i),s=a.loader;o&&!s||(o=qn(r.loader??s));const l=await Jn(a,o),c=await Jn(r,o),h={...Fn(c,l),config:e.mergeConfig(c.config??{},l.config??{})};return await async function(t,n,r={},i){const o=r.theme?e.mergeConfig(un[r.theme],r.config??{}):r.config,a=e.isBoolean(r.actions)?r.actions:Fn({},zn,r.actions??{}),s={...Bn,...r.i18n},l=r.renderer??"canvas",c=r.logLevel??_n.Warn,h=r.downloadFileName??"visualization",f="string"==typeof t?document.querySelector(t):t;if(!f)throw new Error(`${t} does not exist`);if(!1!==r.defaultStyle){const e="vega-embed-style",{root:t,rootContainer:n}=function(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}(f);if(!t.getElementById(e)){const t=document.createElement("style");t.id=e,t.innerHTML=void 0===r.defaultStyle||!0===r.defaultStyle?Cn.toString():r.defaultStyle,n.appendChild(t)}}const p=function(e,t){if(e.$schema){const n=ct(e.$schema);t&&t!==n.library&&console.warn(`The given visualization spec is written in ${Un[n.library]}, but mode argument sets ${Un[t]??t}.`);const r=n.library;return Qe(Gn[r],`^${n.version.slice(1)}`)||console.warn(`The input spec uses ${Un[r]} ${n.version}, but the current version of ${Un[r]} is v${Gn[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":t??"vega"}(n,r.mode);let u=Wn[p](n,o);if("vega-lite"===p&&u.$schema){const e=ct(u.$schema);Qe(Gn.vega,`^${e.version.slice(1)}`)||console.warn(`The compiled spec uses Vega ${e.version}, but current version is v${Gn.vega}.`)}f.classList.add("vega-embed"),a&&f.classList.add("has-actions");f.innerHTML="";let d=f;if(a){const e=document.createElement("div");e.classList.add(Vn),f.appendChild(e),d=e}const g=r.patch;g&&(u=g instanceof Function?g(u):A(u,g,!0,!1).newDocument);r.formatLocale&&_n.formatLocale(r.formatLocale);r.timeFormatLocale&&_n.timeFormatLocale(r.timeFormatLocale);if(r.expressionFunctions)for(const e in r.expressionFunctions){const t=r.expressionFunctions[e];"fn"in t?_n.expressionFunction(e,t.fn,t.visitor):t instanceof Function&&_n.expressionFunction(e,t)}const{ast:m}=r,v=_n.parse(u,"vega-lite"===p?{}:o,{ast:m}),E=new(r.viewClass||_n.View)(v,{loader:i,logLevel:c,renderer:l,...m?{expr:_n.expressionInterpreter??r.expr??lt}:{}});if(E.addSignalListener("autosize",((e,t)=>{const{type:n}=t;"fit-x"==n?(d.classList.add("fit-x"),d.classList.remove("fit-y")):"fit-y"==n?(d.classList.remove("fit-x"),d.classList.add("fit-y")):"fit"==n?d.classList.add("fit-x","fit-y"):d.classList.remove("fit-x","fit-y")})),!1!==r.tooltip){const e="function"==typeof r.tooltip?r.tooltip:new Dn(!0===r.tooltip?{}:r.tooltip).call;E.tooltip(e)}let b,{hover:y}=r;void 0===y&&(y="vega"===p);if(y){const{hoverSet:e,updateSet:t}="boolean"==typeof y?{}:y;E.hover(e,t)}r&&(null!=r.width&&E.width(r.width),null!=r.height&&E.height(r.height),null!=r.padding&&E.padding(r.padding));if(await E.initialize(d,r.bind).runAsync(),!1!==a){let t=f;if(!1!==r.defaultStyle){const e=document.createElement("details");e.title=s.CLICK_TO_VIEW_ACTIONS,f.append(e),t=e;const n=document.createElement("summary");n.innerHTML=Xn,e.append(n),b=t=>{e.contains(t.target)||e.removeAttribute("open")},document.addEventListener("click",b)}const i=document.createElement("div");if(t.append(i),i.classList.add("vega-actions"),!0===a||!1!==a.export)for(const t of["svg","png"])if(!0===a||!0===a.export||a.export[t]){const n=s[`${t.toUpperCase()}_ACTION`],o=document.createElement("a"),a=e.isObject(r.scaleFactor)?r.scaleFactor[t]:r.scaleFactor;o.text=n,o.href="#",o.target="_blank",o.download=`${h}.${t}`,o.addEventListener("mousedown",(async function(e){e.preventDefault();const n=await E.toImageURL(t,a);this.href=n})),i.append(o)}if(!0===a||!1!==a.source){const e=document.createElement("a");e.text=s.SOURCE_ACTION,e.href="#",e.addEventListener("click",(function(e){Hn(j(n),r.sourceHeader??"",r.sourceFooter??"",p),e.preventDefault()})),i.append(e)}if("vega-lite"===p&&(!0===a||!1!==a.compiled)){const e=document.createElement("a");e.text=s.COMPILED_ACTION,e.href="#",e.addEventListener("click",(function(e){Hn(j(u),r.sourceHeader??"",r.sourceFooter??"","vega"),e.preventDefault()})),i.append(e)}if(!0===a||!1!==a.editor){const e=r.editorUrl??"https://vega.github.io/editor/",t=document.createElement("a");t.text=s.EDITOR_ACTION,t.href="#",t.addEventListener("click",(function(t){!function(e,t,n){const r=e.open(t),{origin:i}=new URL(t);let o=40;e.addEventListener("message",(function t(n){n.source===r&&(o=0,e.removeEventListener("message",t,!1))}),!1),setTimeout((function e(){o<=0||(r.postMessage(n,i),setTimeout(e,250),o-=1)}),250)}(window,e,{config:o,mode:p,renderer:l,spec:j(n)}),t.preventDefault()})),i.append(t)}}function w(){b&&document.removeEventListener("click",b),E.finalize()}return{view:E,spec:n,vgSpec:u,finalize:w,embedOptions:r}}(t,i,h,o)}async function Jn(t,n){const r=e.isString(t.config)?JSON.parse(await n.load(t.config)):t.config??{},i=e.isString(t.patch)?JSON.parse(await n.load(t.patch)):t.patch;return{...t,...i?{patch:i}:{},...r?{config:r}:{}}}async function Qn(e,t={}){const n=document.createElement("div");n.classList.add("vega-embed-wrapper");const r=document.createElement("div");n.appendChild(r);const i=!0===t.actions||!1===t.actions?t.actions:{export:!0,source:!1,compiled:!0,editor:!0,...t.actions??{}},o=await Yn(r,e,{actions:i,...t??{}});return n.value=o.view,n}const Zn=(...t)=>{return t.length>1&&(e.isString(t[0])&&!((n=t[0]).startsWith("http://")||n.startsWith("https://")||n.startsWith("//"))||t[0]instanceof HTMLElement||3===t.length)?Yn(t[0],t[1],t[2]):Qn(t[0],t[1]);var n};return Zn.vegaLite=Mn,Zn.vl=Mn,Zn.container=Qn,Zn.embed=Yn,Zn.vega=_n,Zn.default=Yn,Zn.version=Pn,Zn})); 7 | //# sourceMappingURL=vega-embed.min.js.map 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # HappyAPI 2 | 3 | https://github.com/timothypratley/happyapi 4 | -------------------------------------------------------------------------------- /notebooks/happy/notebook/ocr.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.notebook.ocr 2 | (:require [clojure.test :refer [deftest is]] 3 | [happyapi.oauth2.credentials :as credentials] 4 | [happygapi.vision :as vision] 5 | ;; TODO: 6 | [happygapi.documentai :as dai])) 7 | 8 | (deftest t 9 | (credentials/init!) 10 | (vision/images-annotate {:requests [{:image {:source {:imageUri "https://i0.wp.com/static.flickr.com/102/308775600_4ca34de425_o.jpg"}}, 11 | :features [{:type "DOCUMENT_TEXT_DETECTION"}], 12 | :imageContext {:languageHints ["Tamil"]}}]})) 13 | -------------------------------------------------------------------------------- /notebooks/happy/notebook/sheets.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.notebook.sheets 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [happygapi.sheets :as sheets])) 4 | 5 | (def spreadsheet-id "1NbGRyCRMoOT_MLhnubC5900JNwiQq_uqvdKwbqZOfyM") 6 | 7 | #_ 8 | (deftest get$-test 9 | (credentials/init!) 10 | (testing "When fetching a spreadsheet" 11 | (let [spreadsheet (sheets/get$ (credentials/auth!) 12 | {:spreadsheetId spreadsheet-id})] 13 | (is (map? spreadsheet) "should receive spreadsheet info") 14 | (is (seq spreadsheet) "should contain properties"))) 15 | 16 | (testing "When missing a required key" 17 | (is (thrown? AssertionError (sheets/get$ (credentials/auth!) {:badKey "123"})) 18 | "should get an exception"))) 19 | #_ 20 | (deftest values-batchUpdate$-test 21 | (credentials/init!) 22 | (testing "When updating values in a sheet" 23 | (let [response (sheets/values-batchUpdate$ (credentials/auth!) 24 | {:spreadsheetId spreadsheet-id} 25 | {:valueInputOption "USER_ENTERED" 26 | :data [{:range "Sheet1" 27 | :values [[1 2 3] 28 | [4 5 6]]}]})] 29 | (is (map? response) "should receive a summary") 30 | (is (seq response) "containing data")))) 31 | -------------------------------------------------------------------------------- /notebooks/happy/notebook/youtube_clojuretv.clj: -------------------------------------------------------------------------------- 1 | (ns happy.notebook.youtube-clojuretv 2 | (:require [clojure.string :as str] 3 | [scicloj.kindly.v4.kind :as kind] 4 | [happyapi.google.youtube-v3 :as youtube])) 5 | 6 | ;; # ClojureTV video views analysis 7 | 8 | ;; [Clojure/Conj 2024](https://2024.clojure-conj.org/) is coming soon. 9 | ;; As people prepare their talk proposals, it may be interesting to consider what talks have been popular in the past? 10 | ;; In this article we will gather view statistics from the YouTube API and try to answer some questions: 11 | 12 | ;; * What is the distribution of views? 13 | ;; * What are the most viewed talks? 14 | ;; * What are the most liked talks? 15 | ;; * Which talks have a high like per view ratio? 16 | ;; * Which talks have been most commented upon? 17 | 18 | ;; ## Dataset 19 | 20 | ;; We want to get all the videos posted on ClojureTV. 21 | ;; The way to do this is to look at the "uploads" playlist. 22 | ;; First we need to find the channel that ClojureTV videos are published on. 23 | 24 | (defonce channels 25 | (youtube/channels-list "contentDetails,statistics" {:forUsername "ClojureTV"})) 26 | 27 | (def uploads-playlist-id 28 | (get-in (first channels) ["contentDetails" "relatedPlaylists" "uploads"])) 29 | 30 | (defonce playlist 31 | (youtube/playlistItems-list "contentDetails,id" {:playlistId uploads-playlist-id})) 32 | 33 | ;; The playlist contains videoIds which we can use to access video view/like statistics. 34 | 35 | (def video-ids 36 | (mapv #(get-in % ["contentDetails" "videoId"]) playlist)) 37 | 38 | ;; Video details can be requested in batches of at most 50 due to the maximum item count per response page. 39 | 40 | (defonce videos-raw 41 | (vec (mapcat (fn [batch] 42 | (youtube/videos-list "snippet,contentDetails,statistics" {:id (str/join "," batch)})) 43 | (partition-all 50 video-ids)))) 44 | 45 | ;; Let's check how many videos we got 46 | 47 | (count videos-raw) 48 | 49 | ;; And what the data looks like 50 | 51 | (first videos-raw) 52 | 53 | ;; Statistics were interpreted as strings instead of numbers, so we'll need to fix that. 54 | 55 | (def videos 56 | (mapv (fn [video] 57 | (update video "statistics" update-vals parse-long)) 58 | videos-raw)) 59 | 60 | ;; ## Distribution of views 61 | 62 | ;; The first place to start getting a feel for a dataset is often plotting any relevant distributions. 63 | ;; In this case it makes sense to investigate the distribution of view counts per video. 64 | 65 | (kind/vega-lite 66 | {:title "ClojureTV views per video" 67 | :data {:values videos} 68 | :width 400 69 | :layer [{:mark {:type "point" :tooltip true} 70 | :encoding {:x {:field "statistics.viewCount" :type "ordinal" :title "video" :axis {:labels false} :sort "-y"} 71 | :y {:field "statistics.viewCount" :type "quantitative" :title "views"} 72 | :tooltip {:field "snippet.title"}}} 73 | {:mark {:type "rule" :color "red"} 74 | :encoding {:y {:field "statistics.viewCount" :type "quantitative" :aggregate "max"}}}]}) 75 | 76 | ;; Just a few videos get a huge amount of views. 77 | ;; This is a fairly common Pareto style distribution. 78 | ;; We'll be able to understand it better on a log scale. 79 | 80 | (kind/vega-lite 81 | {:title "ClojureTV log scale view quartiles" 82 | :data {:values videos} 83 | :width 400 84 | :layer [{:mark {:type "point" :tooltip true} 85 | :encoding {:x {:field "statistics.viewCount" :type "ordinal" :title "video" :axis {:labels false} :sort "-y"} 86 | :y {:field "statistics.viewCount" :type "quantitative" :title "views" :scale {:type "log"}} 87 | :tooltip {:field "snippet.title"}}} 88 | {:mark {:type "rule" :color "red"} 89 | :encoding {:y {:field "statistics.viewCount" :aggregate "q1"}}} 90 | {:mark {:type "rule" :color "red"} 91 | :encoding {:y {:field "statistics.viewCount" :aggregate "median"}}} 92 | {:mark {:type "rule" :color "red"} 93 | :encoding {:y {:field "statistics.viewCount" :aggregate "q3"}}}]}) 94 | 95 | ;; Now we can more clearly see that most ClojureTV videos get around 4.5k views, with 50% ranging from 2.5k views to 10k views. 96 | 97 | (def view-icon 98 | [:svg {:xmlns "http://www.w3.org/2000/svg" :width "1.13em" :height "1em" :viewBox "0 0 576 512"} 99 | [:path {:fill "currentColor" :d "M288 144a110.94 110.94 0 0 0-31.24 5a55.4 55.4 0 0 1 7.24 27a56 56 0 0 1-56 56a55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144m284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19M288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400"}]]) 100 | 101 | (def like-icon 102 | [:svg {:xmlns "http://www.w3.org/2000/svg" :width "1em" :height "1em" :viewBox "0 0 512 512"} 103 | [:path {:fill "currentColor" :d "M466.27 286.69C475.04 271.84 480 256 480 236.85c0-44.015-37.218-85.58-85.82-85.58H357.7c4.92-12.81 8.85-28.13 8.85-46.54C366.55 31.936 328.86 0 271.28 0c-61.607 0-58.093 94.933-71.76 108.6c-22.747 22.747-49.615 66.447-68.76 83.4H32c-17.673 0-32 14.327-32 32v240c0 17.673 14.327 32 32 32h64c14.893 0 27.408-10.174 30.978-23.95c44.509 1.001 75.06 39.94 177.802 39.94c7.22 0 15.22.01 22.22.01c77.117 0 111.986-39.423 112.94-95.33c13.319-18.425 20.299-43.122 17.34-66.99c9.854-18.452 13.664-40.343 8.99-62.99m-61.75 53.83c12.56 21.13 1.26 49.41-13.94 57.57c7.7 48.78-17.608 65.9-53.12 65.9h-37.82c-71.639 0-118.029-37.82-171.64-37.82V240h10.92c28.36 0 67.98-70.89 94.54-97.46c28.36-28.36 18.91-75.63 37.82-94.54c47.27 0 47.27 32.98 47.27 56.73c0 39.17-28.36 56.72-28.36 94.54h103.99c21.11 0 37.73 18.91 37.82 37.82c.09 18.9-12.82 37.81-22.27 37.81c13.489 14.555 16.371 45.236-5.21 65.62M88 432c0 13.255-10.745 24-24 24s-24-10.745-24-24s10.745-24 24-24s24 10.745 24 24"}]]) 104 | 105 | (def comment-icon 106 | [:svg {:xmlns "http://www.w3.org/2000/svg" :width "1em" :height "1em" :viewBox "0 0 512 512"} 107 | [:path {:fill "currentColor" :d "M256 32C114.6 32 0 125.1 0 240c0 47.6 19.9 91.2 52.9 126.3C38 405.7 7 439.1 6.5 439.5c-6.6 7-8.4 17.2-4.6 26S14.4 480 24 480c61.5 0 110-25.7 139.1-46.3C192 442.8 223.2 448 256 448c141.4 0 256-93.1 256-208S397.4 32 256 32m0 368c-26.7 0-53.1-4.1-78.4-12.1l-22.7-7.2l-19.5 13.8c-14.3 10.1-33.9 21.4-57.5 29c7.3-12.1 14.4-25.7 19.9-40.2l10.6-28.1l-20.6-21.8C69.7 314.1 48 282.2 48 240c0-88.2 93.3-160 208-160s208 71.8 208 160s-93.3 160-208 160"}]]) 108 | 109 | (defn video-summary [{:strs [id] 110 | {:strs [viewCount likeCount commentCount]} "statistics" 111 | {:strs [title description] {{:strs [url]} "default"} "thumbnails"} "snippet"}] 112 | (kind/hiccup 113 | [:div {:style {:display "grid" 114 | :gap "15px" 115 | :grid-template-areas "'t t t t t t' 116 | 'i i i d d d' 117 | 's s s d d d'"}} 118 | [:div {:style {:grid-area "t"}} [:strong title]] 119 | [:div {:style {:grid-area "i"}} [:a {:href (str "https://youtube.com/watch?v=" id) :target "_blank"} 120 | [:img {:src url}]]] 121 | [:div {:style {:grid-area "d"}} description] 122 | [:div {:style {:grid-area "s" 123 | :text-align "right"}} 124 | [:div viewCount " " view-icon] 125 | [:div likeCount " " like-icon] 126 | [:div commentCount " " comment-icon]]])) 127 | 128 | (defn video-table [videos] 129 | (kind/hiccup 130 | [:table {:style {:width "100%"}} 131 | [:thead [:tr 132 | [:th {:style {:text-align "right" 133 | :padding "10px"}} "Rank"] 134 | [:th {:style {:padding "10px"}} "Title"] 135 | [:th {:style {:text-align "right" 136 | :padding "10px"}} "Views"] 137 | [:th {:style {:text-align "right" 138 | :padding "10px"}} "Likes"] 139 | [:th {:style {:text-align "right" 140 | :padding "10px"}} "Comments"] 141 | [:th {:style {:padding "10px"}} "Video"]]] 142 | (into [:tbody] 143 | (map-indexed 144 | (fn [idx {:strs [id] 145 | {:strs [viewCount likeCount commentCount]} "statistics" 146 | {:strs [title] {{:strs [url]} "default"} "thumbnails"} "snippet"}] 147 | [:tr 148 | [:td {:align "right" 149 | :style {:padding "10px"}} (inc idx)] 150 | [:td {:style {:padding "10px"}} title] 151 | [:td {:align "right" 152 | :style {:padding "10px"}} viewCount] 153 | [:td {:align "right" 154 | :style {:padding "10px"}} likeCount] 155 | [:td {:align "right" 156 | :style {:padding "10px"}} commentCount] 157 | [:td {:style {:padding "10px"}} 158 | [:a {:href (str "https://youtube.com/watch?v=" id) :target "_blank"} 159 | [:img {:src url :height 50}]]]]) 160 | videos))])) 161 | 162 | ;; ### Most viewed 163 | 164 | (video-summary (last (sort-by #(get-in % ["statistics" "viewCount"]) videos))) 165 | 166 | ;; The first thing that jumps out at us is that one talk received about 300k views. 167 | ;; It is the famous [Hammock Driven Development](https://www.youtube.com/watch?v=f84n5oFoZBc) talk by Rich Hickey. 168 | ;; I speculate that this talk is especially popular because it tackles broad topics of programming methodologies, 169 | ;; problem-solving, and thinking. 170 | ;; My favourite part is [when he jokes about the Agile sprint, sprint, sprint approach](https://www.youtube.com/watch?v=zPT-DuG0UjU). 171 | ;; His talk provides a refreshing contrast to the Agile formula for success. 172 | ;; If I were to try to categorize this talk I might be tempted toward the label "lifehacks", 173 | ;; but that severely undersells it. 174 | ;; This talk works so well because it shows us who Rich is. 175 | ;; We are pulled in by the beliefs, practices, and lifestyle of the person. 176 | ;; I hope we see more talks at Conj that go outside the box of Clojure. 177 | 178 | ;; ### Top 20 most viewed 179 | 180 | (video-table (take 20 (reverse (sort-by #(get-in % ["statistics" "viewCount"]) videos)))) 181 | 182 | ;; Many of the most viewed Clojure talks were keynotes delivered by heavy hitters Rich Hickey, Brian Goetz, and Guy Steele. 183 | ;; More interesting is that one talk sticks out as very different. 184 | ;; "Every Clojure Talk Ever - Alex Engelberg and Derek Slager" comes in at #7, 185 | ;; and is the complete opposite of the serious and impressive topics surrounding it. 186 | ;; I remember listening to this talk, and it hit close to home for me, leaving me with mixed feelings. 187 | ;; It helped me to stop taking myself so seriously. 188 | ;; My humor has since improved and I now enjoy the spirit in which the talk was delivered. 189 | ;; Clearly Clojurists enjoyed the chance to laugh a little and reflect. 190 | ;; There aren't many Clojure talks that lean this heavily into comedy. 191 | ;; I hope we see a few more presenters take on the challenge to make the audience laugh. 192 | 193 | ;; ### Most liked 194 | 195 | ;; Likes are not available on all videos (for example the most viewed video has private likes). 196 | ;; The owner of the channel can see all likes (and dislikes), but we the public don't. 197 | 198 | (count (filter #(get-in % ["statistics" "likeCount"]) videos)) 199 | 200 | ;; Only about half the talks have likes visible, so we might be missing some well liked videos. 201 | 202 | (video-summary (last (sort-by #(get-in % ["statistics" "likeCount"]) videos))) 203 | 204 | ;; "Every Clojure Talk Ever" comes in at #1 liked, 205 | ;; supporting the notion that people enjoy talks that go outside the box and embrace comedy. 206 | 207 | ;; ### Top 20 most liked 208 | 209 | (video-table (take 20 (reverse (sort-by #(get-in % ["statistics" "likeCount"]) videos)))) 210 | 211 | ;; There is a lot of overlap between viewed and liked. 212 | ;; At #5 "Code goes in, Art comes out - Tyler Hobbs" is another talk that goes outside the box to show beautiful art works. 213 | 214 | ;; ### Most discussed 215 | 216 | (video-summary (last (sort-by #(get-in % ["statistics" "commentCount"]) videos))) 217 | 218 | ;; "Maybe Not" is a talk I had to watch 3 times to digest. 219 | ;; Type theory is the ultimate CompSci topic that people have strong thoughts on, 220 | ;; so there is more comment chatter on this talk. 221 | ;; This talk is very much "in the box"; about technology, computation theory, and the space Clojure occupies in Type theory. 222 | ;; This is a good reminder of the engaging nature of Clojure technology oriented deep dives. 223 | 224 | ;; ### Top 20 most discussed 225 | 226 | (video-table (take 20 (reverse (sort-by #(get-in % ["statistics" "commentCount"]) videos)))) 227 | 228 | ;; The first not by Rich Hickey talk on this list is "Bruce Hauman - Developing ClojureScript With Figwheel", 229 | ;; a fun and engaging talk that presents the wonderful powers of automatic code loading for interactive development. 230 | 231 | ;; ### Hidden gems 232 | 233 | ;; Talks that have a high like:view ratio may indicate they have interesting content. 234 | ;; Again, this only works for the 50% of videos that have likes visible. 235 | 236 | (defn like-ratio [{{:strs [likeCount viewCount]} "statistics"}] 237 | (when likeCount 238 | (/ likeCount viewCount))) 239 | 240 | (video-summary (last (sort-by like-ratio videos))) 241 | 242 | ;; I hadn't seen the Clojure 1.11 chat before, and watching it through, I'm glad I discovered it. 243 | ;; It's quite different from the talks, as it is more of an informal but deep dive into implementation changes in Clojure. 244 | ;; The core team discuss several issues in all their gory technical detail. 245 | ;; Alex, if you read this, I hope that seeing the high like ratio encourages you to keep posting these updates. 246 | 247 | (video-table (take 20 (reverse (sort-by like-ratio videos)))) 248 | 249 | ;; There are many talks on this list that I remember enjoying, which makes me think this can be a helpful metric. 250 | ;; This list turned up some talks that I hadn't watched before; 251 | ;; I enjoyed watching "From Lazy Lisper to Confident Clojurist - Alexander Oloo" for the first time, 252 | ;; and appreciated his conclusions about building communities and choosing problems you care about. 253 | 254 | ;; I was overjoyed to see "How to transfer Clojure goodness to other languages" by Elango Cheran and Timothy Pratley came in at #8 by this metric. 255 | 256 | ;; ### Other ideas 257 | 258 | ;; We've used some obvious metrics to gain some insights into previous talks on ClojureTV. 259 | ;; I think there are deeper analysis that could be performed perhaps using automated feature detection. 260 | ;; It might also be cool to see how this compares to "Strange Loop" videos. 261 | ;; If you are interested in diving deeper with this dataset, or perhaps trying the same investigation for your favorite channel, 262 | ;; the good news is that you can adapt this notebook from the sourcecode. 263 | 264 | ;; ## Aside about accessing YouTube data with HappyGAPI2 265 | 266 | ;; This article uses HappyGAPI2 to call the YouTube API. 267 | ;; I created HappyGAPI about 4 years ago because I wanted to update spreadsheets automatically. 268 | ;; At the time there weren't many (any?) good alternatives for using OAuth2 and consequently GAPI from Clojure. 269 | ;; It made my life easier. 270 | ;; But... I made a few design mistakes which left the implementation rigid. 271 | ;; Recently I spent some time addressing those to make a new more flexible thing called HappyAPI. 272 | 273 | ;; The main goals of HappyAPI are: 274 | 275 | ;; 1. Untangle the OAuth2 client as a library usable in other APIs (not just Google) 276 | ;; 2. Pluggable with other http clients and json encoder/decoders 277 | ;; 3. Easier to use 278 | ;; 3.1. Better organization; one namespace per api, and required arguments as function parameters 279 | ;; 3.2. Don't require users to call `(auth!)` 280 | ;; 3.3. Automate multiple page result retrieval 281 | ;; 3.4. Better docstrings 282 | 283 | ;; I'm happy to say that the new design seems to work well. 284 | ;; But these are breaking changes. 285 | ;; As a result I intend to release a newly named version of HappyGAPI that depends on HappyAPI as a library. 286 | ;; What should I call it? Perhaps `io.github.timothypratley/happyapi` and `io.github.timothypratley/happygapi2`? 287 | 288 | ;; I've put alpha jars on Clojars. 289 | ;; I'd like to get some feedback on the new design, and try to avoid future breaking changes. 290 | ;; Please let me know what you think. 291 | ;; If you have the time, a review of the code at https://github.com/timothypratley/happyapi would be very helpful! 292 | 293 | 294 | ;; ## Conclusion 295 | 296 | ;; We explored the popularity of ClojureTV YouTube videos. 297 | ;; Grabbing data from Google APIs is easier now thanks to HappyGAPI2. 298 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | io.github.timothypratley 5 | happyapi 6 | jar 7 | happyapi 8 | Middleware oriented oauth2 client for webservices 9 | http://github.com/timothypratley/happyapi 10 | 11 | 12 | EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0 13 | https://www.eclipse.org/legal/epl-2.0/ 14 | 15 | 16 | 17 | https://github.com/timothypratley/happyapi 18 | scm:git:git://github.com/timothypratley/happyapi.git 19 | scm:git:ssh://git@github.com/timothypratley/happyapi.git 20 | 21 | 22 | src 23 | test 24 | 25 | 26 | resources 27 | 28 | 29 | 30 | 31 | resources 32 | 33 | 34 | target 35 | target/classes 36 | 37 | 38 | 39 | 40 | clojars 41 | https://repo.clojars.org/ 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | org.clojure 50 | clojure 51 | 1.11.3 52 | 53 | 54 | com.grzm 55 | uri-template 56 | 0.7.1 57 | 58 | 59 | buddy 60 | buddy-sign 61 | 3.5.351 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timothypratley/happyapi/3109177d3276a0a65b63591eba7837ac0e50f379/resources/favicon.ico -------------------------------------------------------------------------------- /src/happyapi/apikey/client.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.apikey.client 2 | (:require [happyapi.middleware :as middleware])) 3 | 4 | (defn make-client 5 | "Given a config map 6 | 7 | {:apikey 8 | :fns {#{:request :query-string :encode :decode} } 9 | :keywordize-keys } 10 | 11 | returns a wrapped request function." 12 | [{:as config 13 | :keys [apikey keywordize-keys] 14 | {:keys [request]} :fns}] 15 | (when-not (middleware/fn-or-var? request) 16 | (throw (ex-info "request must be a function or var" 17 | {:id ::request-must-be-a-function 18 | :request request 19 | :config config}))) 20 | (-> request 21 | (middleware/wrap-cookie-policy-standard) 22 | (middleware/wrap-informative-exceptions) 23 | (middleware/wrap-json config) 24 | (middleware/wrap-apikey-auth apikey) 25 | (middleware/wrap-uri-template) 26 | (middleware/wrap-paging) 27 | (middleware/wrap-extract-result) 28 | (middleware/wrap-keywordize-keys keywordize-keys))) 29 | -------------------------------------------------------------------------------- /src/happyapi/deps.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.deps 2 | "Presents an interface to performing http requests and json encoding/decoding. 3 | Allows easy selection of and migration between libraries that provide these features 4 | and avoids pulling in unnecessary dependencies. 5 | Dependencies are required upon selection.") 6 | 7 | (defn resolve-fn 8 | "Requires, resolves, and derefs to a function identified by sym, will throw on failure" 9 | [sym] 10 | (try (require (symbol (namespace sym))) 11 | (catch Throwable ex 12 | (throw (ex-info (str "Failed to require " sym " - are you missing a dependency?") 13 | {:id ::dependency-ns-not-found 14 | :sym sym} 15 | ex)))) 16 | (or (some-> (ns-resolve *ns* sym) 17 | (deref)) 18 | (throw (ex-info (str "Failed to find " sym " in " (namespace sym)) 19 | {:id ::dependency-fn-not-found 20 | :sym sym})))) 21 | 22 | (defmulti require-dep "Resolves a provider keyword to the functions it provides" identity) 23 | 24 | (defmethod require-dep :httpkit [_] 25 | {:request (let [httpkit-request (resolve-fn 'org.httpkit.client/request)] 26 | (fn request 27 | ([args] @(httpkit-request args)) 28 | ([args respond raise] 29 | (httpkit-request args (fn callback [response] 30 | ;; httpkit doesn't raise, it just puts errors in the response 31 | (if (contains? response :error) 32 | (raise (ex-info "ERROR in response" 33 | {:id ::error-in-response 34 | :resp response})) 35 | (respond response))))))) 36 | :query-string (resolve-fn 'org.httpkit.client/query-string) 37 | :run-server (let [run (resolve-fn 'org.httpkit.server/run-server) 38 | port (resolve-fn 'org.httpkit.server/server-port) 39 | stop! (resolve-fn 'org.httpkit.server/server-stop!)] 40 | (fn httpkit-run-server [handler config] 41 | (let [server (run handler (assoc config :legacy-return-value? false))] 42 | {:port (port server) 43 | :stop (fn [] (stop! server {:timeout 100}))})))}) 44 | (defmethod require-dep :jetty [_] 45 | {:run-server (let [run (resolve-fn 'ring.adapter.jetty/run-jetty)] 46 | (fn jetty-run-server [handler config] 47 | (let [server (run handler (assoc config :join? false))] 48 | (.setStopTimeout server 100) 49 | {:port (-> server .getConnectors first .getLocalPort) 50 | :stop (fn stop-jetty [] 51 | (.stop server))})))}) 52 | (defmethod require-dep :clj-http [_] 53 | {:request (resolve-fn 'clj-http.client/request) 54 | :query-string (resolve-fn 'clj-http.client/generate-query-string)}) 55 | (defmethod require-dep :clj-http.lite [_] 56 | {:request (resolve-fn 'clj-http.lite.client/request) 57 | :query-string (resolve-fn 'clj-http.lite.client/generate-query-string)}) 58 | 59 | (defmethod require-dep :cheshire [_] 60 | {:encode (resolve-fn 'cheshire.core/generate-string) 61 | :decode (resolve-fn 'cheshire.core/parse-string)}) 62 | (defmethod require-dep :jsonista [_] 63 | {:encode (resolve-fn 'jsonista.core/write-value-as-string) 64 | :decode (resolve-fn 'jsonista.core/read-value)}) 65 | (defmethod require-dep :data.json [_] 66 | {:encode (resolve-fn 'clojure.data.json/write-str) 67 | :decode (resolve-fn 'clojure.data.json/read-str)}) 68 | (defmethod require-dep :charred [_] 69 | {:encode (resolve-fn 'charred.api/read-json) 70 | :decode (resolve-fn 'charred.api/write-json-str)}) 71 | 72 | (defn possible 73 | "Returns the valid keys for `choose` and `require-deps`." 74 | [] 75 | (set (keys (methods require-dep)))) 76 | 77 | (defn choose 78 | "Requires dependency providers, and returns a map containing functions. 79 | See `possible-providers` for valid inputs." 80 | ([ks] (apply merge (map require-dep ks)))) 81 | 82 | (defn present 83 | "For informative purposes only. 84 | Shows what dependencies are available. 85 | Will attempt to require all possible dependency providers." 86 | [] 87 | (into {} 88 | (for [[k f] (methods require-dep)] 89 | [k (try (f k) (catch Throwable ex))]))) 90 | -------------------------------------------------------------------------------- /src/happyapi/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.middleware 2 | "Wrapping facilitates an abstract http-request rather than a specific implementation, 3 | and allows for configuration of cross-cutting concerns." 4 | (:require [clojure.string :as str] 5 | [clojure.walk :as walk] 6 | [com.grzm.uri-template :as uri-template])) 7 | 8 | (defn success? 9 | [{:keys [status]}] 10 | (and (number? status) 11 | (<= 200 status 299))) 12 | 13 | (defn fn-or-var? 14 | [f] 15 | (or (fn? f) (var? f))) 16 | 17 | (defn wrap-cookie-policy-standard [request] 18 | (fn 19 | ([args] 20 | (request (assoc args :cookie-policy :standard))) 21 | ([args respond raise] 22 | (request (assoc args :cookie-policy :standard) respond raise)))) 23 | 24 | (defn informative-exception [id ex args] 25 | (ex-info (str "Failed " (or (some-> (:method args) (name) (str/upper-case)) 26 | "no :method provided") 27 | " " (or (:url args) "no :url provided") 28 | " " (ex-message ex)) 29 | {:id id 30 | :args args} 31 | ex)) 32 | 33 | (defn wrap-informative-exceptions [request] 34 | (fn 35 | ([args] 36 | (try 37 | (request args) 38 | (catch Exception ex 39 | (throw (informative-exception ::request-failed ex args))))) 40 | ([args respond raise] 41 | (request args respond 42 | (fn [ex] 43 | (raise (informative-exception ::request-failed-async ex args))))))) 44 | 45 | (defn paging-interrupted [ex items] 46 | ;; items collected so far are added to the exception so that they may be retrieved. 47 | (if (seq items) 48 | (ex-info "Paging interrupted" 49 | {:id ::paging-interrupted 50 | :items items} 51 | ex) 52 | ex)) 53 | 54 | (defn request-pages-async [request args respond raise items] 55 | (request args 56 | (fn [resp] 57 | (let [items (into items (get-in resp [:body "items"])) 58 | resp (assoc-in resp [:body "items"] items) 59 | nextPageToken (get-in resp [:body "nextPageToken"])] 60 | (if nextPageToken 61 | (request-pages-async request (assoc-in args [:query-params :pageToken] nextPageToken) respond raise items) 62 | (respond resp)))) 63 | (fn [ex] 64 | (raise (paging-interrupted ex items))))) 65 | 66 | (defn request-pages [request args] 67 | (loop [page nil 68 | items []] 69 | (let [args (if page 70 | (assoc-in args [:query-params :pageToken] page) 71 | args) 72 | resp (try 73 | (request args) 74 | (catch Throwable ex 75 | (throw (paging-interrupted ex items)))) 76 | items (into items (get-in resp [:body "items"])) 77 | resp (assoc-in resp [:body "items"] items) 78 | nextPageToken (get-in resp [:body "nextPageToken"])] 79 | (if nextPageToken 80 | (if (= page nextPageToken) 81 | (throw (paging-interrupted (ex-info "nextPageToken did not change while paging" 82 | {:id ::invalid-nextPageToken 83 | :nextPageToken nextPageToken}) 84 | items)) 85 | (recur nextPageToken items)) 86 | resp)))) 87 | 88 | ;; TODO: should there be a way to monitor progress and perhaps stop looping? 89 | ;; TODO: would it be interesting to provide a lazy iteration version? probably not, seems like a bad idea 90 | (defn wrap-paging 91 | "When fetching collections, will request all pages. 92 | This may take a long time. 93 | `wrap-paging` must come before `wrap-deitemize` when used together" 94 | [request] 95 | (fn paging 96 | ([args] 97 | (request-pages request args)) 98 | ([args respond raise] 99 | (request-pages-async request args respond raise [])))) 100 | 101 | (defn maybe-update [args k f & more] 102 | (if (contains? args k) 103 | (apply update args k f more) 104 | args)) 105 | 106 | (defn enjsonize [args encode] 107 | (-> (maybe-update args :body encode) 108 | (update :headers merge {"Content-Type" "application/json" 109 | "Accept" "application/json"}))) 110 | 111 | (defn json? [resp] 112 | (some->> (get-in resp [:headers :content-type]) 113 | (re-find #"^application/(.+\+)?json"))) 114 | 115 | (defn dejsonize [args resp decode keywordize-keys] 116 | (if (json? resp) 117 | (maybe-update resp :body #(cond-> (decode %) 118 | (and (not (false? (:keywordize-keys args))) 119 | (or keywordize-keys (:keywordize-keys args))) 120 | (walk/keywordize-keys))) 121 | resp)) 122 | 123 | (defn wrap-json 124 | "Converts the body of responses to a data structure. 125 | Pluggable json implementations resolved from dependencies, or can be passed as an argument. 126 | Keywordization can be enabled with :keywordize-keys true. 127 | 128 | Error responses don't throw exceptions when parsing fails. 129 | Success responses that fail to parse are rethrown with the response and request as context." 130 | [request {:as config 131 | :keys [keywordize-keys] 132 | {:keys [encode decode]} :fns}] 133 | (when-not (and (fn-or-var? encode) 134 | (fn-or-var? decode)) 135 | (throw (ex-info "JSON dependency invalid" 136 | {:id ::json-dependency-invalid 137 | :encode encode 138 | :decode decode 139 | :config config}))) 140 | (fn 141 | ([args] 142 | (let [args (enjsonize args encode) 143 | resp (request args)] 144 | (try 145 | (dejsonize args resp decode keywordize-keys) 146 | (catch Throwable ex 147 | (if (success? resp) 148 | (throw (ex-info "Failed to json decode the body of a successful response" 149 | {:id ::parse-json-failed 150 | :response resp 151 | :args args} 152 | ex)) 153 | ;; errors often have non-json bodies, presumably users want to handle those if we got here 154 | resp))))) 155 | ([args respond raise] 156 | (request (-> (enjsonize args encode)) 157 | (fn [resp] 158 | (try 159 | (-> (dejsonize args resp decode keywordize-keys) 160 | (respond)) 161 | (catch Throwable ex 162 | (if (success? resp) 163 | (raise (ex-info "Failed to json decode the body of a successful async response" 164 | {:id ::parse-json-failed-async 165 | :response resp 166 | :args args} 167 | ex)) 168 | ;; errors often have non-json bodies, presumably users want to handle those if we got here 169 | (respond resp))))) 170 | raise)))) 171 | 172 | ;; TODO: surely there are other cases to consider? 173 | (defn remove-redundant-data-labels [x] 174 | (if (map? x) 175 | (cond (contains? x "data") (recur (get x "data")) 176 | (seq (get x "items")) (mapv remove-redundant-data-labels (get x "items")) 177 | :else x) 178 | x)) 179 | 180 | (defn extract-result [{:keys [body]}] 181 | (remove-redundant-data-labels body)) 182 | 183 | (defn wrap-extract-result 184 | "When we call an API, we want the logical result of the call, not the map containing body, and status. 185 | We also don't need to preserve the type of arrays, so we can remove that layer of indirection (:items is unnecessary). 186 | When using this middleware, you should also use a client or middleware that throws when status indicates failure, 187 | to prevent logical results when there is an error." 188 | [request] 189 | (fn 190 | ([args] 191 | (extract-result (request args))) 192 | ([args respond raise] 193 | (request args 194 | (fn [resp] 195 | (respond (extract-result resp))) 196 | raise)))) 197 | 198 | (defn maybe-keywordize-keys [args resp keywordize-keys] 199 | (if (and (not (false? (:keywordize-keys args))) 200 | (or keywordize-keys (:keywordize-keys args))) 201 | (walk/keywordize-keys resp) 202 | resp)) 203 | 204 | (defn wrap-keywordize-keys [request keywordize-keys] 205 | (fn 206 | ([args] 207 | (maybe-keywordize-keys args (request args) keywordize-keys)) 208 | ([args respond raise] 209 | (request args 210 | (fn [resp] 211 | (respond (maybe-keywordize-keys args resp keywordize-keys))) 212 | raise)))) 213 | 214 | (defn uri-from-template [{:as args :keys [uri-template uri-template-args]}] 215 | (if uri-template 216 | (assoc args :url (uri-template/expand uri-template uri-template-args)) 217 | args)) 218 | 219 | (defn wrap-uri-template 220 | "Arguments to APIs may appear in the url path, query-string, or body of a request. 221 | This middleware assists with the correct application of path arguments. 222 | When :uri-template is present, it adds :url which is the application of the template with :uri-template-args. 223 | See https://datatracker.ietf.org/doc/html/rfc6570 for more information about uri-templates." 224 | [request] 225 | (fn 226 | ([args] (request (uri-from-template args))) 227 | ([args respond raise] (request (uri-from-template args) respond raise)))) 228 | 229 | ;; TODO: paging should save progress? or is it ok with informative exceptions? 230 | ;; TODO: metering? Seeing as this is a pass through wrapper, just recommend that library right?! 231 | 232 | (defn apikey-param 233 | "Given credentials, returns a header suitable for merging into a request." 234 | [args apikey] 235 | (assoc-in args [:query-params "key"] apikey)) 236 | 237 | (defn bearer-header 238 | [args bearer] 239 | (assoc-in args [:headers "Authorization"] (str "Bearer " bearer))) 240 | 241 | (defn wrap-apikey-auth [request apikey] 242 | {:pre [(string? apikey)]} 243 | (fn 244 | ([args] 245 | (request (apikey-param args apikey))) 246 | ([args respond raise] 247 | (request (apikey-param args apikey) respond raise)))) 248 | 249 | (defn wrap-debug [request] 250 | (fn 251 | ([args] 252 | (println "DEBUG request: " args) 253 | (doto (request args) 254 | (->> (println "DEBUG response:")))) 255 | ([args response raise] 256 | (println "DEBUG async request: " args) 257 | (request args 258 | (fn [resp] 259 | (println "DEBUG async response: " resp) 260 | (response resp)) 261 | raise)))) 262 | -------------------------------------------------------------------------------- /src/happyapi/oauth2/auth.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.auth 2 | "Helpers for getting an OAuth 2.0 token. 3 | See https://developers.google.com/identity/protocols/OAuth2WebServer" 4 | (:require [clojure.string :as str] 5 | [clojure.set :as set] 6 | [buddy.sign.jwt :as jwt] 7 | [buddy.core.keys :as keys] 8 | [happyapi.middleware :as middleware]) 9 | (:import (java.util Date) 10 | (java.util Base64))) 11 | 12 | (set! *warn-on-reflection* true) 13 | 14 | (defn provider-login-url 15 | "Step 1: Set authorization parameters. 16 | Builds the URL to send the user to for them to authorize your app. 17 | For local testing you can paste this URL into your browser, 18 | or call (clojure.java.browse/browse-url (provider-login-url my-config scopes optional)). 19 | In your app you need to send your user to this URL, usually with a redirect response. 20 | For valid optional params, see https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1, 21 | noting that `state` is strongly recommended." 22 | ([config scopes] (provider-login-url config scopes nil)) 23 | ([{:as config 24 | :keys [auth_uri client_id redirect_uri] 25 | {:keys [query-string]} :fns} 26 | scopes 27 | optional] 28 | (let [params (merge {:client_id client_id 29 | :response_type "code" 30 | :redirect_uri redirect_uri 31 | :scope (str/join " " scopes)} 32 | optional)] 33 | (str auth_uri "?" (query-string params))))) 34 | 35 | ;; Step 2: Redirect to Google's OAuth 2.0 server. 36 | 37 | ;; Step 3: Google prompts user for consent. 38 | ;; Sit back and wait. 39 | ;; There should be a route in your app to handle the redirect from Google (see step 4). 40 | ;; happyapioauth2-capture-redirect shows how you could do this, 41 | ;; and is useful if you don't want to run a server. 42 | 43 | ;; Step 4: Handle the OAuth 2.0 server response 44 | 45 | (defn with-timestamp 46 | "The server won't give us the time of day, so let's check our clock." 47 | [{:as credentials :keys [expires_in]}] 48 | (if expires_in 49 | (assoc credentials 50 | :expires_at (Date. ^long (+ (* expires_in 1000) (System/currentTimeMillis)))) 51 | credentials)) 52 | 53 | (defn base64 [^String to-encode] 54 | (.encodeToString (Base64/getEncoder) (.getBytes to-encode))) 55 | 56 | (defn exchange-code 57 | "Step 5: Exchange authorization code for refresh and access tokens. 58 | When the user is redirected back to your app from Google with a short-lived code, 59 | exchange the code for a long-lived access token." 60 | [request 61 | {:as config :keys [token_uri client_id client_secret redirect_uri]} 62 | code 63 | code_verifier] 64 | (let [resp (request {:method :post 65 | :url token_uri 66 | ;; Google documentation says client_id and client_secret should be parameters, 67 | ;; but accepts them in the Basic Auth header (undocumented). 68 | ;; Other providers require them as Basic Auth header. 69 | :headers {"Authorization" (str "Basic " (base64 (str client_id ":" client_secret)))} 70 | ;; RFC 6749: form encoded params 71 | :form-params (cond-> {:code code 72 | :grant_type "authorization_code" 73 | :redirect_uri redirect_uri} 74 | code_verifier (assoc :code_verifier code_verifier)) 75 | :keywordize-keys true})] 76 | (when (middleware/success? resp) 77 | (with-timestamp (:body resp))))) 78 | 79 | (defn refresh-credentials 80 | "Given a config map, and a credentials map containing either a refresh_token or private_key, 81 | fetches a new access token. 82 | Returns credentials if successful (a map containing an access token). 83 | Refresh tokens eventually expire, and attempts to refresh will fail with 401. 84 | Therefore, calls that could cause a refresh should catch 401 exceptions, 85 | call set-authorization-parameters and redirect." 86 | [request 87 | {:as config :keys [token_uri client_id client_secret client_email private_key]} 88 | scopes 89 | {:as credentials :keys [refresh_token]}] 90 | (try 91 | (let [now (quot (.getTime (Date.)) 1000) 92 | params (cond private_key 93 | {:grant_type "urn:ietf:params:oauth:grant-type:jwt-bearer" 94 | :assertion (jwt/sign 95 | {:iss client_email, 96 | :scope (str/join " " scopes), 97 | :aud token_uri 98 | :exp (+ now 3600) 99 | :iat now} 100 | (keys/str->private-key private_key) 101 | {:alg :rs256 102 | :header {:alg "RS256" 103 | :typ "JWT"}})} 104 | 105 | refresh_token 106 | {:client_id client_id 107 | :client_secret client_secret 108 | :grant_type "refresh_token" 109 | :refresh_token refresh_token} 110 | 111 | :else (throw (ex-info "Refresh missing token" 112 | {:id ::refresh-missing-token}))) 113 | resp (request {:url token_uri 114 | :method :post 115 | :form-params params 116 | :keywordize-keys true})] 117 | (when (middleware/success? resp) 118 | (with-timestamp (:body resp)))) 119 | (catch Exception ex 120 | ;; TODO: should probably only swallow 401? 121 | ))) 122 | 123 | (defn revoke-token 124 | "Given a credentials map containing either an access token or refresh token, revokes it." 125 | [request 126 | {:as config :keys [token_uri]} 127 | {:as credentials :keys [access_token refresh_token]}] 128 | (request {:method :post 129 | :url (str/replace token_uri #"/token$" "/revoke") 130 | ;; may be form-params or json body 131 | :body {"token" (or access_token 132 | refresh_token 133 | (throw (ex-info "Credentials missing token" 134 | {:id ::credentials-missing-token 135 | :credentials credentials})))} 136 | :keywordize-keys true})) 137 | 138 | (defn valid? [{:as credentials :keys [expires_at access_token]}] 139 | (boolean 140 | (and access_token 141 | (or (not expires_at) 142 | (neg? (.compareTo (Date.) expires_at)))))) 143 | 144 | (defn refreshable? [{:as config :keys [private_key]} {:as credentials :keys [refresh_token]}] 145 | (boolean (or refresh_token private_key))) 146 | 147 | (defn credential-scopes [credentials] 148 | (set (some-> (:scope credentials) (str/split #" ")))) 149 | 150 | (defn has-scopes? [credentials scopes] 151 | ;; TODO: scopes have a hierarchy 152 | (set/subset? (set scopes) (credential-scopes credentials))) 153 | -------------------------------------------------------------------------------- /src/happyapi/oauth2/capture_redirect.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.capture-redirect 2 | "Reference for receiving a token in a redirect from the oauth provider. 3 | If you are making a web app, implement a route in your app that captures the code parameter. 4 | If you use this namespace, add ring as a dependency in your project." 5 | (:require [clojure.java.browse :as browse] 6 | [clojure.java.io :as io] 7 | [clojure.set :as set] 8 | [happyapi.middleware :as middleware] 9 | [happyapi.oauth2.auth :as oauth2] 10 | [ring.middleware.params :as params])) 11 | 12 | (set! *warn-on-reflection* true) 13 | 14 | (def login-timeout 15 | "If the user doesn't log in after 2 minutes, stop waiting." 16 | (* 2 60 1000)) 17 | 18 | (defn browse-to-provider [config scopes optional] 19 | (-> (oauth2/provider-login-url config scopes optional) 20 | (browse/browse-url))) 21 | 22 | (defn make-redirect-handler [p] 23 | (-> (fn redirect-handler [{:as req :keys [request-method uri params]}] 24 | (case [request-method uri] 25 | [:get "/favicon.ico"] {:body (io/file (io/resource "favicon.ico")) 26 | :status 200} 27 | (if (get @(deliver p params) "code") 28 | {:status 200 29 | :body "Code received, authentication successful."} 30 | {:status 400 31 | :body "No code in response."}))) 32 | (params/wrap-params))) 33 | 34 | (defn fresh-credentials 35 | "Opens a browser to authenticate, waits for a redirect, and returns a code. 36 | Defaults access_type to offline, 37 | state to a random uuid which is checked when redirected back, 38 | and include_granted_scopes true." 39 | [request {:as config :keys [redirect_uri authorization_options fns]} scopes] 40 | (let [p (promise) 41 | [match protocol host _ requested-port path] (re-find #"^(https?://)(localhost|127.0.0.1)(:(\d+))?(/.*)?$" redirect_uri) 42 | _ (when-not match 43 | (throw (ex-info "redirect_uri should match http://localhost" 44 | {:id ::bad-redirect-uri 45 | :redirect_uri redirect_uri 46 | :config config}))) 47 | 48 | ;; Port 80 is a privileged port that requires root permissions, which may be problematic for some users. 49 | ;; Google allows the redirect_uri port to vary, other providers do not. 50 | ;; A random (or specified) port is a natural choice. 51 | ;; Put port 0 in the redirect_uri to activate random port selection. 52 | port (if requested-port 53 | (Integer/parseInt requested-port) 54 | 80) 55 | {:keys [run-server]} fns 56 | {:keys [port stop]} (run-server (make-redirect-handler p) {:port port}) 57 | ;; The port may have changed when requesting a random port 58 | config (if requested-port 59 | (assoc config :redirect_uri (str protocol host ":" port path)) 60 | config) 61 | ;; Twitter requires a PKCE challenge. 62 | ;; Challenges are for the provider server checking, state is for client checking. 63 | ;; We use the same value for both state and challenge. 64 | state-and-challenge (str (random-uuid)) 65 | {:keys [code_challenge_method]} authorization_options 66 | ;; access_type offline and prompt consent together result in a refresh token (that both are necessary is undocumented by Google afaik) 67 | ;; most web-apps wouldn't do this, it is intended for desktop apps, which is the anticipated usage of this namespace 68 | optional (merge authorization_options 69 | {:state state-and-challenge} 70 | (when code_challenge_method 71 | {:code_challenge state-and-challenge})) 72 | ;; send the user to the provider to authenticate and authorize. 73 | ;; this url includes the redirect_uri as a query param, 74 | ;; so we must provide port chosen by our local server 75 | _ (browse-to-provider config scopes optional) 76 | ;; wait for the user to get redirected to localhost with a code 77 | {:strs [code state] :as return-params} (deref p login-timeout nil)] 78 | (stop) 79 | (if code 80 | (do 81 | (when-not (= state state-and-challenge) 82 | (throw (ex-info "Redirected state does not match request" 83 | {:id ::redirected-state-mismatch 84 | :optional optional 85 | :return-params return-params}))) 86 | ;; exchange the code with the provider for credentials 87 | ;; (must have the same config as browse, the redirect_uri needs the correct port) 88 | (oauth2/exchange-code request config code (when code_challenge_method state-and-challenge))) 89 | (throw (ex-info "Login timeout, no code received." 90 | {:id ::login-timeout}))))) 91 | 92 | (defn update-credentials 93 | "Use credentials if valid, refresh if necessary, or get new credentials. 94 | For valid optional params, see https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1" 95 | ([request config credentials scopes] 96 | ;; scopes can grow 97 | (let [scopes (set/union (oauth2/credential-scopes credentials) (set scopes))] 98 | ;; merge to retain refresh token 99 | (merge credentials 100 | (or 101 | ;; already have valid credentials 102 | (and (oauth2/valid? credentials) 103 | (oauth2/has-scopes? credentials scopes) 104 | credentials) 105 | ;; try to refresh existing credentials 106 | (and (oauth2/refreshable? config credentials) 107 | (oauth2/has-scopes? credentials scopes) 108 | (oauth2/refresh-credentials (middleware/wrap-keywordize-keys request true) config scopes credentials)) 109 | ;; new credentials required 110 | (fresh-credentials (middleware/wrap-keywordize-keys request true) config scopes)))))) 111 | -------------------------------------------------------------------------------- /src/happyapi/oauth2/client.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.client 2 | (:require [clojure.string :as str] 3 | [happyapi.middleware :as middleware] 4 | [happyapi.oauth2.capture-redirect :as capture-redirect] 5 | [happyapi.oauth2.credentials :as credentials])) 6 | 7 | (def required-config [:provider :client_id :client_secret :auth_uri :token_uri :redirect_uri]) 8 | 9 | (defn missing-config [config] 10 | (seq (for [k required-config 11 | :when (not (get config k))] 12 | k))) 13 | 14 | (defmulti endpoints identity) 15 | (defmethod endpoints :default [_] nil) 16 | (defmethod endpoints :google [_] 17 | {:auth_uri "https://accounts.google.com/o/oauth2/auth" 18 | :token_uri "https://oauth2.googleapis.com/token" 19 | ;; port 0 selects a random port 20 | :redirect_uri "http://localhost:0/redirect" 21 | :authorization_options {:access_type "offline" 22 | :prompt "consent" 23 | :include_granted_scopes true}}) 24 | (defmethod endpoints :github [_] 25 | {:auth_uri "https://github.com/login/oauth/authorize" 26 | :token_uri "https://github.com/login/oauth/access_token" 27 | ;; port 0 selects a random port 28 | :redirect_uri "http://localhost:0/redirect"}) 29 | (defmethod endpoints :twitter [_] 30 | {:auth_uri "https://twitter.com/i/oauth2/authorize" 31 | :token_uri "https://api.twitter.com/2/oauth2/token" 32 | :redirect_uri "http://localhost:8080/redirect" 33 | :authorization_options {:code_challenge_method "plain"}}) 34 | 35 | (defn with-endpoints 36 | "The only configuration required is to know the provider (for endpoints), 37 | the client-id and the client-secret. 38 | This helper adds the endpoints for a given provider." 39 | [{:as config :keys [provider]}] 40 | (if provider 41 | (merge (endpoints provider) config) 42 | config)) 43 | 44 | (defn oauth2 45 | "Performs a http-request that includes oauth2 credentials. 46 | Will obtain fresh credentials prior to the request if necessary. 47 | See https://developers.google.com/identity/protocols/oauth2 for more information." 48 | [request args config] 49 | (let [{:keys [provider]} config 50 | {:keys [user scopes] :or {user "user" scopes (:scopes config)}} args 51 | credentials (credentials/read-credentials provider user) 52 | credentials (capture-redirect/update-credentials request config credentials scopes) 53 | {:keys [access_token]} credentials] 54 | (credentials/save-credentials provider user credentials) 55 | (if access_token 56 | (request (middleware/bearer-header args access_token)) 57 | (throw (ex-info (str "Failed to obtain credentials for " user) 58 | {:id ::failed-credentials 59 | :user user 60 | :scopes scopes}))))) 61 | 62 | (defn oauth2-async 63 | "Only the http request is asynchronous; reading, updating, or writing credentials is done synchronously. 64 | Asynchronously obtaining credentials is challenging because it may rely on waiting for a redirect back. 65 | This compromise allows for convenient usage, but means that calls may block while authorizing before 66 | the http request is made." 67 | [request args config respond raise] 68 | (let [{:keys [provider]} config 69 | {:keys [user scopes] :or {user "user" scopes (:scopes config)}} args 70 | credentials (credentials/read-credentials provider user) 71 | credentials (capture-redirect/update-credentials request config credentials scopes) 72 | {:keys [access_token]} credentials] 73 | (credentials/save-credentials provider user credentials) 74 | (if access_token 75 | (request (middleware/apikey-param args access_token) respond raise) 76 | (raise (ex-info (str "Async failed to obtain credentials for " user) 77 | {:id ::async-failed-credentials 78 | :user user 79 | :scopes scopes}))))) 80 | 81 | (defn check [config] 82 | (when-let [ks (missing-config config)] 83 | (throw (ex-info (str "Invalid oauth2 config: missing " (str/join "," ks)) 84 | {:id ::invalid-config 85 | :missing (vec ks) 86 | :config config}))) 87 | config) 88 | 89 | (defn wrap-oauth2 90 | "Wraps a http-request function that uses keys user and scopes from args to authorize according to config." 91 | [request config] 92 | (let [config (with-endpoints config)] 93 | (when-let [ks (missing-config config)] 94 | (throw (ex-info (str "Invalid config: missing " (str/join "," ks)) 95 | {:id ::invalid-config 96 | :missing (vec ks) 97 | :config config}))) 98 | (when-not (middleware/fn-or-var? request) 99 | (throw (ex-info "request must be a function or var" 100 | {:id ::request-must-be-a-function 101 | :request request 102 | :request-type (type request)}))) 103 | (fn 104 | ([args] 105 | (oauth2 request args config)) 106 | ([args respond raise] 107 | (oauth2-async request args config respond raise))))) 108 | 109 | (defn make-client 110 | "Given a config map 111 | 112 | {#{:client_id :client_secret} 113 | :provider #{:google :amazon :github :twitter ...} 114 | #{:auth_uri :token_uri} 115 | :fns {#{:request :query-string :encode :decode} } 116 | :keywordize-keys } 117 | 118 | Returns a wrapped request function. 119 | 120 | If `provider` is known, then auth_uri and token_uri endpoints will be added to the config, 121 | otherwise expects `auth_uri` and `token_uri`. 122 | `provider` is required to namespace tokens, but is not restricted to known providers. 123 | Dependencies are passed as functions in `fns`." 124 | [{:as config :keys [keywordize-keys] {:keys [request]} :fns}] 125 | (when-not (middleware/fn-or-var? request) 126 | (throw (ex-info "request must be a function or var" 127 | {:id ::request-must-be-a-function 128 | :request request 129 | :config config}))) 130 | (-> request 131 | (middleware/wrap-cookie-policy-standard) 132 | (middleware/wrap-informative-exceptions) 133 | (middleware/wrap-json config) 134 | (wrap-oauth2 config) 135 | (middleware/wrap-uri-template) 136 | (middleware/wrap-paging) 137 | (middleware/wrap-extract-result) 138 | (middleware/wrap-keywordize-keys keywordize-keys))) 139 | -------------------------------------------------------------------------------- /src/happyapi/oauth2/credentials.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.credentials 2 | "Reference for how to manage credentials. 3 | For a web app, you should implement something like this but use your database for credential storage. 4 | secret, scopes, fetch, save have default implementations that you can replace with init!" 5 | (:require [clojure.java.io :as io] 6 | [clojure.edn :as edn])) 7 | 8 | (set! *warn-on-reflection* true) 9 | 10 | ;; TODO: Nope, this needs to be provider specific (at least!) 11 | 12 | (def *credentials-cache 13 | (atom nil)) 14 | 15 | (defn read-credentials [provider user] 16 | (or (get-in @*credentials-cache [provider user]) 17 | (let [credentials-file (io/file "tokens" (name provider) (str user ".edn"))] 18 | (when (.exists credentials-file) 19 | (edn/read-string (slurp credentials-file)))))) 20 | 21 | (defn delete-credentials [provider user] 22 | (swap! *credentials-cache update provider dissoc user) 23 | (.delete (io/file (io/file "tokens" (name provider)) (str user ".edn")))) 24 | 25 | (defn write-credentials [provider user credentials] 26 | (swap! *credentials-cache assoc-in [provider user] credentials) 27 | (spit (io/file (doto (io/file "tokens" (name provider)) (.mkdirs)) 28 | (str user ".edn")) 29 | credentials)) 30 | 31 | (defn save-credentials [provider user new-credentials] 32 | (when (not= (get-in @*credentials-cache [provider user]) new-credentials) 33 | (if new-credentials 34 | (write-credentials provider user new-credentials) 35 | (delete-credentials provider user)))) 36 | -------------------------------------------------------------------------------- /src/happyapi/providers/amazon.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.amazon 2 | (:require [happyapi.setup :as setup])) 3 | 4 | (declare api-request) 5 | 6 | (defn set-request! [client] 7 | (alter-var-root #'api-request (constantly client))) 8 | 9 | (defn setup! 10 | "Changes `api-request` to be a configured client. 11 | config is provider specific, 12 | it should contain `:client_id` and `:client_secret` for oauth2, 13 | or `:apikey`. 14 | See config/make-client for more options." 15 | [config] (set-request! (setup/make-client (when config {:amazon config}) :amazon))) 16 | 17 | (defn api-request 18 | "A function to handle API requests. 19 | Can be configured with `setup!`. 20 | Will attempt to configure itself if not previously configured. 21 | May also be replaced by a custom stack of middleware constructed in a different way. 22 | This is what generated code invokes, which means that customizations here 23 | will be present in the generated interface." 24 | ([args] 25 | (setup! nil) 26 | (api-request args)) 27 | ([args respond raise] 28 | (try 29 | (setup! nil) 30 | (api-request args respond raise) 31 | (catch Throwable ex 32 | (raise ex))))) 33 | -------------------------------------------------------------------------------- /src/happyapi/providers/github.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.github 2 | (:require [happyapi.setup :as setup])) 3 | 4 | (declare api-request) 5 | 6 | (defn set-request! [client] 7 | (alter-var-root #'api-request (constantly client))) 8 | 9 | (defn setup! 10 | "Changes `api-request` to be a configured client. 11 | config is provider specific, 12 | it should contain `:client_id` and `:client_secret` for oauth2, 13 | or `:apikey`. 14 | See config/make-client for more options." 15 | [config] (set-request! (setup/make-client (when config {:github config}) :github))) 16 | 17 | (defn api-request 18 | "A function to handle API requests. 19 | Can be configured with `setup!`. 20 | Will attempt to configure itself if not previously configured. 21 | May also be replaced by a custom stack of middleware constructed in a different way. 22 | This is what generated code invokes, which means that customizations here 23 | will be present in the generated interface." 24 | ([args] 25 | (setup! nil) 26 | (api-request args)) 27 | ([args respond raise] 28 | (try 29 | (setup! nil) 30 | (api-request args respond raise) 31 | (catch Throwable ex 32 | (raise ex))))) 33 | -------------------------------------------------------------------------------- /src/happyapi/providers/google.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.google 2 | (:require [happyapi.setup :as setup])) 3 | 4 | (declare api-request) 5 | 6 | (defn set-request! [client] 7 | (alter-var-root #'api-request (constantly client))) 8 | 9 | (defn setup! 10 | "Changes `api-request` to be a configured client. 11 | config is provider specific, 12 | it should contain `:client_id` and `:client_secret` for oauth2, 13 | or `:apikey`. 14 | See config/make-client for more options." 15 | [config] (set-request! (setup/make-client (when config {:google config}) :google))) 16 | 17 | (defn api-request 18 | "A function to handle API requests. 19 | Can be configured with `setup!`. 20 | Will attempt to configure itself if not previously configured. 21 | May also be replaced by a custom stack of middleware constructed in a different way. 22 | This is what generated code invokes, which means that customizations here 23 | will be present in the generated interface." 24 | ([args] 25 | (setup! nil) 26 | (api-request args)) 27 | ([args respond raise] 28 | (try 29 | (setup! nil) 30 | (api-request args respond raise) 31 | (catch Throwable ex 32 | (raise ex))))) 33 | -------------------------------------------------------------------------------- /src/happyapi/providers/twitter.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.twitter 2 | (:require [happyapi.setup :as setup])) 3 | 4 | (declare api-request) 5 | 6 | (defn set-request! [client] 7 | (alter-var-root #'api-request (constantly client))) 8 | 9 | (defn setup! 10 | "Changes `api-request` to be a configured client. 11 | config is provider specific, 12 | it should contain `:client_id` and `:client_secret` for oauth2, 13 | or `:apikey`. 14 | See config/make-client for more options." 15 | [config] (set-request! (setup/make-client (when config {:twitter config}) :twitter))) 16 | 17 | (defn api-request 18 | "A function to handle API requests. 19 | Can be configured with `setup!`. 20 | Will attempt to configure itself if not previously configured. 21 | May also be replaced by a custom stack of middleware constructed in a different way. 22 | This is what generated code invokes, which means that customizations here 23 | will be present in the generated interface." 24 | ([args] 25 | (setup! nil) 26 | (api-request args)) 27 | ([args respond raise] 28 | (try 29 | (setup! nil) 30 | (api-request args respond raise) 31 | (catch Throwable ex 32 | (raise ex))))) 33 | -------------------------------------------------------------------------------- /src/happyapi/setup.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.setup 2 | (:require [clojure.edn :as edn] 3 | [clojure.java.io :as io] 4 | [clojure.walk :as walk] 5 | [happyapi.apikey.client :as apikey.client] 6 | [happyapi.deps :as deps] 7 | [happyapi.middleware :as middleware] 8 | [happyapi.oauth2.client :as oauth2.client])) 9 | 10 | (defn as-map 11 | "If a string, will coerce as edn. 12 | If given a map, will return it unchanged." 13 | [config] 14 | (cond (map? config) config 15 | (string? config) (let [{:keys [decode]} (deps/require-dep :cheshire)] 16 | (walk/keywordize-keys (decode config))) 17 | (nil? config) config 18 | :else (throw (ex-info "Unexpected configuration type" 19 | {:id ::unexpected-configuration-type 20 | :config-type (type config) 21 | :config config})))) 22 | 23 | (defn read-edn [s source] 24 | (try 25 | (edn/read-string s) 26 | (catch Throwable ex 27 | (throw (ex-info (str "Unreadable config, not EDN read from " (name source)) 28 | {:id ::config-not-edn 29 | :source source 30 | :config s}))))) 31 | 32 | ;; A standard way to search for config 33 | ;; check environment variables, files, resources, anything else? 34 | ;; should the logic be provider specific? 35 | ;; IDEA: The redirect server can request the client_secret, allowing you to use a password manager. 36 | (defn find-config 37 | "Looks for HAPPYAPI_CONFIG in the environment, then a file happyapi.edn, 38 | then happyapi.edn resource, else nil" 39 | [] 40 | (let [config-file-name "happyapi.edn"] 41 | (or (some-> (System/getenv "HAPPYAPI_CONFIG") (read-edn :environment)) 42 | (let [f (io/file config-file-name)] 43 | (when (.exists f) 44 | (-> (slurp f) 45 | (read-edn :file)))) 46 | (when-let [r (io/resource config-file-name)] 47 | (-> (slurp r) 48 | (read-edn :resource)))))) 49 | 50 | (defn with-deps 51 | "Selection of implementation functions for http and json, 52 | based on either the :deps or :fns of the config." 53 | [{:as config :keys [deps fns]}] 54 | (if deps 55 | (update config :fns #(merge (deps/choose deps) %)) 56 | (when-not fns 57 | (throw (ex-info "Client configuration requires :deps like [:clj-http :cheshire] or :fns to be supplied" 58 | {:id ::config-missing-deps 59 | :config config}))))) 60 | 61 | (defn resolve-fns 62 | [config] 63 | (update config :fns (fn [fns] 64 | (into fns (for [[k f] fns 65 | :when (qualified-symbol? f)] 66 | [k (deps/resolve-fn f)]))))) 67 | 68 | (defn prepare-config 69 | "Prepares configuration by resolving dependencies. 70 | Checks that the necessary configuration is present, throws an exception if not. 71 | See docstring for make-client for typical configuration inputs." 72 | [provider config] 73 | (when-not provider 74 | (throw (ex-info "Provider is required" 75 | {:id ::provider-required 76 | :provider provider 77 | :config config}))) 78 | (let [config (if (nil? config) 79 | (find-config) 80 | (as-map config)) 81 | config (-> (get config provider) 82 | (update :provider #(or % provider)) 83 | (with-deps) 84 | (resolve-fns)) 85 | {:keys [client_id apikey fns]} config 86 | {:keys [request]} fns] 87 | (when-not (middleware/fn-or-var? request) 88 | (throw (ex-info "request must be a function or var" 89 | {:id ::request-must-be-a-function 90 | :request request 91 | :request-type (type request)}))) 92 | (cond client_id (-> (oauth2.client/with-endpoints config) 93 | (oauth2.client/check)) 94 | apikey config 95 | :else (throw (ex-info "Missing config, expected `:client_id` or `:apikey`" 96 | {:id ::missing-config 97 | :config config}))))) 98 | 99 | (defn make-client 100 | "Returns a function that can make requests to an api. 101 | 102 | If `config` is nil, will attempt to find edn config in the environment HAPPYAPI_CONFIG, 103 | or a file happyapi.edn 104 | 105 | If specified, `config` should be a map: 106 | 107 | {:google {:deps [:clj-http :cheshire] ;; see happyapi.deps for alternatives 108 | :fns {...} ;; if you prefer to provide your own dependencies 109 | :client_id \"MY_ID\" ;; oauth2 client_id of your app 110 | :client_secret \"MY_SECRET\" ;; oauth2 client_secret from your provider 111 | :apikey \"MY_API_KEY\" ;; only when not using oauth2 112 | :scopes [] ;; optional default scopes used when none present in requests 113 | :keywordize-keys false ;; optional, defaults to true 114 | :provider :google}} ;; optional, use another provider urls and settings 115 | 116 | The `provider` argument is required and should match a top level key to use (other configs may be present)." 117 | [config provider] 118 | (let [config (prepare-config provider config) 119 | {:keys [client_id apikey]} config] 120 | (cond client_id (oauth2.client/make-client config) 121 | apikey (apikey.client/make-client config)))) 122 | -------------------------------------------------------------------------------- /test/happyapi.edn: -------------------------------------------------------------------------------- 1 | {:test-provider {:auth_uri "RESOURCE" 2 | :client_id "RESOURCE" 3 | :redirect_uri "http://localhost"}} 4 | -------------------------------------------------------------------------------- /test/happyapi/apikey/client_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.apikey.client-test 2 | (:require [clojure.edn :as edn] 3 | [clojure.test :refer :all] 4 | [happyapi.apikey.client :as client] 5 | [happyapi.deps :as deps])) 6 | 7 | (deftest make-client-test 8 | (let [config (-> (edn/read-string (slurp "happyapi.edn")) 9 | (:google) 10 | (select-keys [:apikey]) 11 | (assoc :provider :google)) 12 | kit-request (client/make-client (assoc config :fns (deps/choose [:httpkit :cheshire]))) 13 | req {:method :get 14 | :url "https://youtube.googleapis.com/youtube/v3/channels" 15 | :query-params {:part "contentDetails,statistics" 16 | :forUsername "ClojureTV"}} 17 | resp (kit-request req) 18 | [{:strs [kind]}] resp] 19 | (is (= kind "youtube#channel")))) 20 | -------------------------------------------------------------------------------- /test/happyapi/deps_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.deps-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [com.grzm.uri-template :as uri-template] 4 | [happyapi.deps :as deps])) 5 | 6 | (deftest dependencies-resolved-test 7 | (is (deps/choose [:httpkit :jsonista])) 8 | (is (deps/present)) 9 | (is (deps/possible)) 10 | (is (thrown? Exception (deps/choose [:bad-key])))) 11 | 12 | (deftest get-url-test 13 | (is (= "BASE/a/B/c" 14 | (uri-template/expand "BASE/a/{b}/c" {:b "B"}))) 15 | (is (= "BASE/a/B/c" 16 | (uri-template/expand "BASE/a/{+b}/c" {:b "B"})))) 17 | -------------------------------------------------------------------------------- /test/happyapi/gen/google/beaver_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.beaver-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [happyapi.gen.google.beaver :as beaver] 4 | [happyapi.gen.google.raven :as raven] 5 | [meander.epsilon :as m])) 6 | 7 | (def sheets-api (raven/get-json "https://sheets.googleapis.com/$discovery/rest?version=v4")) 8 | 9 | (defmacro is-match? [x pattern] 10 | `(is (= (m/match ~x ~pattern 'test/match ~'_else ~x) 'test/match))) 11 | 12 | (deftest method-name-test 13 | (is (= 'bar-baz-booz (beaver/method-sym {"id" "foo.bar.baz.booz"})))) 14 | 15 | (deftest extract-method-test 16 | (let [method (beaver/extract-method 17 | [sheets-api {"id" "sheets.spreadsheet.method" 18 | "path" "path" 19 | "parameters" {"spreadsheetId" {"required" true} 20 | "range" {}} 21 | "description" "description" 22 | "scopes" ["scope"] 23 | "httpMethod" "POST"}])] 24 | (is (list? method)) 25 | (is-match? method (defn (m/pred symbol? ?fn-name) (m/pred string? ?doc-string) & _)))) 26 | 27 | (deftest build-nss-test 28 | (is (seq (beaver/build-api-ns sheets-api)))) 29 | -------------------------------------------------------------------------------- /test/happyapi/gen/google/lion_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.lion-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [happyapi.gen.google.lion :as lion])) 4 | 5 | (deftest pprint-str-test 6 | (is (= "[1 2 3]\n" (lion/pprint-str [1 2 3])))) 7 | 8 | (deftest ns-str-test 9 | (is (= "foo\n\n\"a\nb\nc\"\n\nbar\n\nbaz\n" (lion/ns-str '(foo "a\nb\nc" bar baz))))) 10 | -------------------------------------------------------------------------------- /test/happyapi/gen/google/monkey_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.gen.google.monkey-test 2 | (:require [clojure.test :refer [deftest testing is]] 3 | [happyapi.gen.google.monkey :as monkey] 4 | [happyapi.gen.google.raven :as raven])) 5 | 6 | (deftest fetch-test 7 | (let [api (raven/get-json "https://sheets.googleapis.com/$discovery/rest?version=v4")] 8 | (is (map? api) "should deserialize") 9 | (is (seq api) "should contain definition"))) 10 | 11 | (deftest list-apis-test 12 | (let [api-list (monkey/list-apis)] 13 | (is (<= 100 (count api-list) 500) 14 | "should find a good number of Google APIs"))) 15 | -------------------------------------------------------------------------------- /test/happyapi/middleware_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.middleware-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [happyapi.middleware :as middleware] 4 | [happyapi.deps :as deps])) 5 | 6 | (deftest success?-test 7 | (is (middleware/success? {:status 200}))) 8 | 9 | (defn raise [ex] 10 | (throw ex)) 11 | 12 | (deftest wrap-uri-template-test 13 | (let [request (-> (fn 14 | ([args] 15 | (is (= (:url args) "fooBARbaz"))) 16 | ([args respond raise] 17 | (is (= (:url args) "fooBARbaz")))) 18 | (middleware/wrap-uri-template)) 19 | args {:uri-template "foo{bar}baz" 20 | :uri-template-args {:bar "BAR"}}] 21 | (request args) 22 | (request args (fn [resp]) raise))) 23 | 24 | (deftest wrap-deitemize-test 25 | (let [request (-> (fn 26 | ([args] 27 | {:status 200 28 | :body {"items" [1 2 3]}}) 29 | ([args respond raise] 30 | (respond {:status 200 31 | :body {"items" [4 5 6]}}))) 32 | (middleware/wrap-extract-result))] 33 | (is (= (request {}) [1 2 3])) 34 | (request {} 35 | (fn [resp] 36 | (is (= resp [4 5 6]))) 37 | raise))) 38 | 39 | (deftest wrap-json-test 40 | (let [request-stub (fn 41 | ([args] 42 | {:status 200 43 | :headers {:content-type "application/json"} 44 | :body "{\"json\": 1, \"edn\": 0}"}) 45 | ([args respond raise] 46 | (respond {:status 200 47 | :headers {:content-type "application/json"} 48 | :body "{\"sync\": 1, \"async\": 0}"}))) 49 | json (deps/require-dep :cheshire) 50 | request (middleware/wrap-json request-stub {:fns json}) 51 | request-keywordized (-> (middleware/wrap-json request-stub {:fns json}) 52 | (middleware/wrap-keywordize-keys true))] 53 | (is (= (:body (request {})) 54 | {"json" 1 55 | "edn" 0})) 56 | (is (= (:body (request-keywordized {})) 57 | {:json 1 58 | :edn 0})) 59 | (request {} 60 | (fn [resp] 61 | (is (= (:body resp) {"sync" 1 62 | "async" 0}))) 63 | raise) 64 | (request-keywordized {} 65 | (fn [resp] 66 | (is (= (:body resp) {:sync 1 67 | :async 0}))) 68 | raise))) 69 | 70 | (deftest wrap-paging-test 71 | (let [responses [[1 2 3] 72 | [4 5 6] 73 | [7 8 9] 74 | [10 11 12] 75 | [13 14 15]] 76 | counter (atom -1) 77 | request (-> (fn 78 | ([args] 79 | (let [c (swap! counter inc)] 80 | {:status 200 81 | :body {"items" (get responses c) 82 | "nextPageToken" (case c 83 | 0 "page2" 84 | 4 "page6" 85 | 5 (throw (ex-info "OH NO" {:id ::oh-no})) 86 | nil)}})) 87 | ([args respond raise] 88 | (let [c (swap! counter inc)] 89 | (respond {:status 200 90 | :body {"items" (get responses c) 91 | "nextPageToken" (case c 92 | 2 "page4" 93 | nil)}})))) 94 | (middleware/wrap-paging) 95 | (middleware/wrap-extract-result))] 96 | (is (= (request {}) [1 2 3 4 5 6])) 97 | (request {} 98 | (fn [resp] 99 | (is (= resp [7 8 9 10 11 12]))) 100 | raise) 101 | (is (= {:id :happyapi.middleware/paging-interrupted 102 | :items [13 14 15]} 103 | (try (request {}) (catch Throwable ex (ex-data ex))))))) 104 | 105 | (deftest wrap-informative-exceptions-test 106 | (let [request (-> (deps/choose [:clj-http :cheshire]) 107 | (:request) 108 | (middleware/wrap-informative-exceptions))] 109 | (is (thrown? Exception (request {:method :get :url "bad-url.not.a/oh-no"}))) 110 | (is (thrown? Exception (request {:method :get :url "https://bad-url.not.au/oh-no"}))))) 111 | -------------------------------------------------------------------------------- /test/happyapi/oauth2/auth_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.auth-test 2 | (:require [clojure.edn :as edn] 3 | [clojure.test :refer [deftest is]] 4 | [happyapi.middleware :as middleware] 5 | [happyapi.oauth2.auth :as auth] 6 | [happyapi.oauth2.capture-redirect :as capture-redirect] 7 | [happyapi.oauth2.client :as client] 8 | [happyapi.oauth2.credentials :as credentials] 9 | [happyapi.setup :as setup])) 10 | 11 | ;; To run the tests you need a happyapi.edn file with secrets in it. 12 | ;; This is a particularly annoying test because it revokes, forcing you to login in again. 13 | (deftest refresh-and-revoke-test 14 | (let [provider :google 15 | config (-> (slurp "happyapi.edn") (edn/read-string) (get provider) 16 | (assoc :provider :google) 17 | (client/with-endpoints) 18 | (setup/with-deps)) 19 | request (middleware/wrap-json (get-in config [:fns :request]) config) 20 | credentials (credentials/read-credentials provider "user") 21 | scopes ["https://www.googleapis.com/auth/userinfo.email"] 22 | credentials (capture-redirect/update-credentials request config credentials scopes)] 23 | (credentials/save-credentials provider "user" credentials) 24 | (and 25 | (is credentials) 26 | (is (auth/refreshable? config credentials)) 27 | (let [credentials (merge (auth/refresh-credentials request config scopes credentials) 28 | credentials)] 29 | (credentials/save-credentials provider "user" credentials) 30 | (and 31 | (is credentials) 32 | (is (middleware/success? (auth/revoke-token request config credentials))) 33 | (credentials/delete-credentials provider "user")))))) 34 | -------------------------------------------------------------------------------- /test/happyapi/oauth2/capture_redirect_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.capture-redirect-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure.string :as str] 4 | [happyapi.deps :as deps] 5 | [happyapi.oauth2.capture-redirect :as capture-redirect] 6 | [happyapi.oauth2.auth :as auth] 7 | [clj-http.client :as http])) 8 | 9 | (deftest wait-for-redirect-test 10 | (with-redefs [capture-redirect/browse-to-provider (fn browse-to-provider [{:as config :keys [redirect_uri]} scopes optional] 11 | (http/get (str (str/replace redirect_uri "https" "http") 12 | "?code=CODE&state=" (:state optional)))) 13 | auth/exchange-code (fn exhange-code [request config code challenge] 14 | (is (= "CODE" code)) 15 | {:access_token "TOKEN"})] 16 | (let [config {:auth_uri "TEST" 17 | :client_id "TEST" 18 | :redirect_uri "http://localhost" 19 | :fns (deps/require-dep :httpkit)}] 20 | (is (= {:access_token "TOKEN"} 21 | (capture-redirect/fresh-credentials http/request 22 | (assoc config :redirect_uri "http://localhost") 23 | []))) 24 | (is (= {:access_token "TOKEN"} 25 | (capture-redirect/fresh-credentials http/request 26 | (assoc config :redirect_uri "http://127.0.0.1") 27 | []))) 28 | (is (= {:access_token "TOKEN"} 29 | (capture-redirect/fresh-credentials http/request 30 | (assoc config :redirect_uri "https://localhost") 31 | []))) 32 | (is (= {:access_token "TOKEN"} 33 | (capture-redirect/fresh-credentials http/request 34 | (assoc config :redirect_uri "http://localhost:8080/redirect") 35 | []))) 36 | (is (thrown? Throwable 37 | (capture-redirect/fresh-credentials http/request 38 | (assoc config :redirect_uri "http://not.localhost") 39 | [])))))) 40 | -------------------------------------------------------------------------------- /test/happyapi/oauth2/client_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.oauth2.client-test 2 | (:require [clojure.edn :as edn] 3 | [clojure.test :refer [deftest is]] 4 | [happyapi.oauth2.client :as client] 5 | [happyapi.deps :as deps])) 6 | 7 | (deftest auth2-test 8 | (let [config (-> (edn/read-string (slurp "happyapi.edn")) 9 | (:google) 10 | (assoc :provider :google)) 11 | clj-request (client/make-client (assoc config :fns (deps/choose [:clj-http :jetty :cheshire]))) 12 | kit-request (client/make-client (assoc config :fns (deps/choose [:httpkit :cheshire]))) 13 | req {:method :get 14 | :url "https://openidconnect.googleapis.com/v1/userinfo" 15 | :scopes ["https://www.googleapis.com/auth/userinfo.email"] 16 | :query-params {}} 17 | resp1 (clj-request req) 18 | _ (is (get-in resp1 ["email"])) 19 | resp2 (kit-request req) 20 | _ (is (get-in resp2 ["email"]))])) 21 | -------------------------------------------------------------------------------- /test/happyapi/providers/github_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.github-test 2 | (:require [clojure.test :refer :all] 3 | [happyapi.providers.github :as github])) 4 | 5 | (deftest api-request-test 6 | (github/setup! nil) 7 | (is (-> (github/api-request {:method :get 8 | :url "https://api.github.com/user" 9 | :scopes ["user" "user:email"]}) 10 | (get "email")))) 11 | -------------------------------------------------------------------------------- /test/happyapi/providers/google_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.google-test 2 | (:require [clojure.test :refer :all] 3 | [happyapi.providers.google :as google])) 4 | 5 | (deftest api-request-test 6 | (google/setup! nil) 7 | (let [channels (google/api-request {:method :get 8 | :url "https://youtube.googleapis.com/youtube/v3/channels" 9 | :query-params {:part "contentDetails,statistics" 10 | :forUsername "ClojureTV"} 11 | :scopes ["https://www.googleapis.com/auth/youtube.readonly"]})] 12 | (is (seq channels)))) 13 | -------------------------------------------------------------------------------- /test/happyapi/providers/twitter_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.providers.twitter-test 2 | (:require [clojure.test :refer :all] 3 | [happyapi.providers.twitter :as twitter])) 4 | 5 | (deftest api-request-test 6 | (twitter/setup! nil) 7 | (is (-> (twitter/api-request {:method :get 8 | :url "https://api.twitter.com/2/users/me" 9 | :scopes ["tweet.read" "tweet.write" "users.read"]}) 10 | (get "username"))) 11 | (is (-> (twitter/api-request {:method :delete 12 | :url "https://api.twitter.com/2/tweets/1811986925798195513" 13 | :scopes ["tweet.read" "tweet.write" "users.read"]}) 14 | (get "deleted"))) 15 | ;; let's not post every time I run the tests... 16 | #_(twitter/api-request {:method :post 17 | :url "https://api.twitter.com/2/tweets" 18 | :scopes ["tweet.read" "tweet.write" "users.read"] 19 | :body {:text "This is a test tweet from HappyAPI"}})) 20 | -------------------------------------------------------------------------------- /test/happyapi/setup_test.clj: -------------------------------------------------------------------------------- 1 | (ns happyapi.setup-test 2 | (:require [clojure.test :refer [deftest is]] 3 | [clojure.java.io :as io] 4 | [happyapi.setup :as setup]) 5 | (:import [java.io File] 6 | [java.util UUID])) 7 | 8 | (def config-file-name "happyapi.edn") 9 | (def config-env-name "HAPPYAPI_CONFIG") 10 | 11 | (def file-config 12 | {:test-provider {:auth_uri "FILE" 13 | :client_id "FILE" 14 | :redirect_uri "http://localhost"}}) 15 | 16 | (deftest find-config-test 17 | (let [old-file-name config-file-name 18 | old-file (io/file old-file-name) 19 | old-file? (.exists old-file) 20 | helper-fn (fn helper-fn [expected-auth-uri-value] 21 | (let [config (setup/find-config)] 22 | (is (= expected-auth-uri-value (:auth_uri (:test-provider config)))))) 23 | test-fn (fn test-fn [] 24 | (when-not (System/getenv config-env-name) ; punt for now if config in env 25 | (helper-fn "RESOURCE") 26 | (try (spit config-file-name (pr-str file-config)) 27 | (helper-fn "FILE") 28 | (finally (io/delete-file config-file-name)))))] 29 | ;; We have to check for an existing happyapi.edn since other tests seem to require it. 30 | (if old-file? 31 | ;; save and restore any existing happyapi.edn 32 | (let [parent (.getParentFile old-file) 33 | saved-config-file (File. (str "happyapi" (UUID/randomUUID) ".saved" parent)) 34 | saved-config-file-name (.getName saved-config-file)] 35 | (try (io/delete-file saved-config-file-name true) ;renameTo needs a non-existing destination 36 | (.renameTo old-file saved-config-file) 37 | (test-fn) 38 | (finally (io/delete-file old-file true) ;renameTo needs a non-existing destination 39 | (.renameTo saved-config-file old-file)))) 40 | (test-fn)))) 41 | -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 {} 2 | --------------------------------------------------------------------------------