├── .dir-locals.el ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── bb.edn ├── bin ├── kaocha ├── proj └── start_metabase ├── deps.edn ├── dev ├── config.edn └── user.clj ├── pom.xml ├── repl_sessions ├── create_db_conn.clj ├── dashboard_card.clj ├── demo.clj ├── find_db.clj ├── init.clj ├── lookup_id.clj ├── pagination.clj └── session.clj ├── src └── lambdaisland │ ├── embedkit.clj │ └── embedkit │ ├── repl.clj │ ├── setup.clj │ └── watch.clj └── tests.edn /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil . ((cider-clojure-cli-global-options . "-A:dev:test") 2 | (cider-preferred-build-tool . clojure-cli) 3 | (cider-redirect-server-output-to-repl . t) 4 | (cider-repl-display-help-banner . nil) 5 | (clojure-toplevel-inside-comment-form . t)))) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache 2 | .nrepl-port 3 | target 4 | repl 5 | scratch.clj 6 | .shadow-cljs 7 | target 8 | yarn.lock 9 | node_modules/ 10 | .DS_Store 11 | resources/public/ui 12 | .store 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | ## Added 4 | 5 | ## Fixed 6 | 7 | ## Changed 8 | 9 | # 0.0.66 (2023-12-05 / 9ce088a) 10 | 11 | ## Added 12 | - Extend the Connection protocol to clojure.lang.Atom, so that we can have 13 | connection in immutable object or mutable object. With connection object 14 | mutable, we can refresh it when the token expires. 15 | 16 | ## Fixed 17 | 18 | ## Changed 19 | 20 | 21 | # 0.0.56 (2023-04-14 / fd0bc4a) 22 | 23 | ## Added 24 | 25 | ## Fixed 26 | 27 | ## Changed 28 | 29 | - [breaking] Support only metabase version >= `0.46.1` 30 | - Change the API call parameters on `/api/dashboard/:id/cards` 31 | 32 | # 0.0.50 (2023-01-19 / 8e058ff) 33 | 34 | ## Added 35 | 36 | - Let (setup/init-metabase! config) support first-name, last-name, site-name. 37 | 38 | ## Fixed 39 | 40 | ## Changed 41 | 42 | # 0.0.45 (2022-11-24 / f6273b8) 43 | 44 | ## Added 45 | 46 | - Add fetch-users API 47 | 48 | ## Fixed 49 | 50 | ## Changed 51 | 52 | - [breaking] Support only metabase version >= `0.40.0` 53 | - Support the pagination feature of `/api/user` [Ref](https://github.com/metabase/metabase/wiki/What%27s-new-in-0.40.0-for-Metabase-REST-API-clients) 54 | 55 | # 0.0.24 (2022-11-13 / 23678d6) 56 | 57 | ## Added 58 | 59 | - Add setup automation 60 | 61 | # 0.0.12 (2021-10-28 / 756d3d1) 62 | 63 | ## Added 64 | 65 | - First release -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lambdaisland/embedkit 2 | 3 | 4 | 5 | 6 | 7 | Use Metabase as a dashboard engine 8 | 9 | 10 | 11 |   12 | 13 | 14 | 15 |   16 | 17 | ## Support Lambda Island Open Source 18 | 19 | embedkit is part of a growing collection of quality Clojure libraries and 20 | tools released on the Lambda Island label. If you are using this project 21 | commercially then you are expected to pay it forward by 22 | [becoming a backer on Open Collective](http://opencollective.com/lambda-island#section-contribute), 23 | so that we may continue to enjoy a thriving Clojure ecosystem. 24 | 25 |   26 | 27 |   28 | 29 | 30 | 31 | ## Sponsors 32 | 33 | Initial development of EmbedKit is generously sponsored by [Eleven](https://runeleven.com). 34 | 35 | ## Features 36 | 37 | 38 | ## Installation 39 | deps.edn 40 | 41 | ``` 42 | lambdaisland/embedkit {:mvn/version "0.0.56"} 43 | ``` 44 | 45 | project.clj 46 | 47 | ``` 48 | [lambdaisland/embedkit "0.0.56"] 49 | ``` 50 | 51 | 52 | ## Rationale 53 | 54 | Metabase is able to talk to many different data sources, and to turn what it 55 | finds into attractive dashboards, with many options for how to visualize the 56 | results. These dashboards can then be embedded as iframes. 57 | 58 | This library allows you to use Metabase as a dashboard engine for your 59 | application, creating embeddable dashboards on the fly based on a pure data 60 | (EDN) specification of the dashboard, and the cards thereon. 61 | 62 | It takes care of all the low level plumbing, as well as many inconsistencies in 63 | Metabase's API, and provides higher-level operations for creating multiple 64 | related entities in one go. 65 | 66 | It uses content-addressed caching to reuse previously created cards and 67 | dashboards. The assumption when using this library is that these entities are 68 | immutable, if you need a different one, just create a different one. 69 | 70 | ## Usage 71 | 72 | Let's start with a teaser 73 | 74 | ``` clojure 75 | (def conn (e/connect {:user "admin@example.com" :password "..." :secret-key "..."})) 76 | 77 | (def db (e/find-database conn "orders")) 78 | 79 | (def dashboard (->> (e/dashboard {:name "My sales dashboard" 80 | :cards [{:card (-> (e/native-card {:name "Monthly revenue" 81 | :database db 82 | :sql {:select ["month" "SUM(amount) AS total"] 83 | :from ["orders"] 84 | :group-by ["month"] 85 | :order-by ["month"]}}) 86 | (e/bar-chart {:x-axis ["month"] 87 | :y-axis ["total"]})) 88 | :width 12 :height 10}]}) 89 | (e/find-or-create! conn))) 90 | 91 | ;; Open the dashboard in the browser, REPL helper for local testing 92 | (r/browse! dashboard) 93 | 94 | ;; Get an embed-url that you can use in an iframe 95 | (e/embed-url conn dashboard) 96 | ``` 97 | 98 | Let's pick that apart, first you need to create a connection: 99 | 100 | ``` clojure 101 | (def conn (e/connect {:user "admin@example.com" 102 | :password "..." 103 | ;; See the metabase embed settings for this 104 | :secret-key "..." 105 | :host "localhost" 106 | :port 3000 107 | :https? false?})) 108 | ``` 109 | 110 | This does the initial HTTP call to Metabase to request an authorization token. 111 | The result is a record that encapsulates everything we need to know to talk to 112 | the API. There are a few more options related to the underlying HTTP client. 113 | This also wraps an atom which serves as a cache. 114 | 115 | After connecting you are expected to also call `populate-cache`. This will allow 116 | EmbedKit to reuse cards and dashboards based on their content hash. 117 | 118 | ``` clojure 119 | (e/populate-cache conn) 120 | ``` 121 | 122 | Next you can find the database you want to create dashboards for. 123 | 124 | ``` clojure 125 | (def db (e/find-database "orders")) 126 | ``` 127 | 128 | This is just a little helper to find a database in Metabase by name. We 129 | generally go to great lengths to prevent having to deal with Metabase's 130 | incremental ids outside of Metabase. This needs to fetch the full list of 131 | databases, but these are then cached in memory. 132 | 133 | EmbedKit is heavily data-driven. You first create an EDN representation of the 134 | entity you want to create. For "questions" (what Metabase internally calls 135 | Cards) you start with the `native-card` function. Currently only native (SQL) 136 | queries are supported. 137 | 138 | ```clojure 139 | (e/native-card {:name "Monthly revenue" 140 | :database db 141 | :sql {:select ["month" "SUM(amount) AS total"] 142 | :from ["orders"] 143 | :group-by ["month"] 144 | :order-by ["month"]}}) 145 | ``` 146 | 147 | You can pass a `:database` entity or a `:database-id` numeric id, if you have 148 | it. `:sql` can be a string or a map, if it's a map we run it through HoneySQL. 149 | 150 | This returns an "entity map", which looks like this: 151 | 152 | ``` clojure 153 | {:lambdaisland.embedkit/type :card 154 | :name "Monthly revenue" 155 | :database_id {:id 2} 156 | :query_type "native" 157 | :dataset_query {:database {:id 2} 158 | :type "native" 159 | :native 160 | {:query "SELECT month SUM(amount) AS total FROM orders GROUP BY month ORDER BY month"}} 161 | :display "table" 162 | :visualization_settings {} 163 | :lambdaisland.embedkit/variables {}} 164 | ``` 165 | 166 | The keys that are namespaced (`:lambdaisland.embedkit/type` and 167 | `:lambdaisland.embedkit/variables`) are for EmbedKit's own use, to figure out 168 | the correct API endpoint for a given resource, and to correctly wire up multiple 169 | entities (think: dashboard -> dashboard-card -> card), everything else is in the 170 | format that the Metabase API expects. 171 | 172 | Once you have an entity map like this you can run it through `find-or-create!`. 173 | 174 | ``` clojure 175 | (e/find-or-create! conn (my-card)) 176 | ``` 177 | 178 | This will check the local cache, using a hash of the data. If it didn't find a 179 | match, then a new entity gets created. Either way what you get back is the 180 | representation of this entity as returned by the Metabase API, augmented with 181 | our embedkit-specific keys. 182 | 183 | There are also functions which adjust the entity description, for instance 184 | `bar-chart`, which changes how the result is rendered. 185 | 186 | ``` clojure 187 | (-> (e/native-card {...}) 188 | (e/bar-chart {:x-axis ["..."] :y-axis ["..."]}) 189 | ``` 190 | 191 | Using these you can build up your own functions, describing the cards you are 192 | want to display. Finally you get to put them together in a dashboard. 193 | 194 | ``` clojure 195 | (e/find-or-create! 196 | conn 197 | (e/dashboard {:name "My sales dashboard" 198 | :cards [{:card (my-card-fn) 199 | :x 5 :y 0 200 | :width 12 :height 10}]})) 201 | ``` 202 | 203 | This will create the dashboards, the cards, and then the dashboard cards. 204 | 205 | Finally you can pass the result of `find-or-create!` to `embed-url` to get a URL 206 | you can use to create an iframe. The result is a `lambdaisland.uri`, call `str` 207 | on it to get the URL as a string. 208 | 209 | ### Variables 210 | 211 | Metabase allows you to create "variables" for queries/cards, hook these up to 212 | "parameters" of dashboards, and fill them in when creating embed-urls. This 213 | requires definitions in three different places. This is one of the things that 214 | is extremely opaque to do via the API. We simplify this by taking variable 215 | definitions on the cards, and wiring these up automatically to dashboard-cards, 216 | and exposing them in embed urls via the signed payload ("locked" parameters). 217 | 218 | The main use case so far is to allow reusing a single dashboard definition 219 | containing some placeholder variables. 220 | 221 | ``` clojure 222 | (e/native-card {:variables {:category {}} 223 | :sql {:where [:= "category" "{{category}}"}) 224 | ``` 225 | 226 | - Use `{{var_name}}` placeholders in your SQL 227 | - Add a corresponding entry in the `:variables` map. The associated key is a map 228 | with variable-specific options, like `:type`. It can be left empty. The 229 | default `:type` is `"text"`. 230 | 231 | When using this to create a dashboard, corresponding parameter will be created 232 | for the dashboard, which will be set to "embeddable, locked". That is, you can 233 | set them via the JWT-signed payload, but the user can't set them via the URL. 234 | 235 | When calling `embed-url` you can pass values for these variables. 236 | 237 | ``` clojure 238 | (e/embed-url conn (e/find-or-create! (my-dashboard)) {:variables {:category "toys"}}) 239 | ``` 240 | 241 | ### Other utilities 242 | 243 | #### Initialize the metabase 244 | See the example file from `repl_sessions/init.clj` 245 | 246 | ``` 247 | (def config {:user "admin@example.com" 248 | :password "xxxxxx"}) 249 | ;; create admin user and enable embedded 250 | (setup/init-metabase! config) 251 | 252 | ;; setup embedding secret key 253 | (e/mb-put conn* 254 | [:setting :embedding-secret-key] 255 | {:form-params {:value "6fa6b6600d27ff276d3d0e961b661fb3b082f8b60781e07d11b8325a6e1025c5"}}) 256 | 257 | ;; get the embedding secret key 258 | (def config* (assoc config 259 | :secret-key (get 260 | (setup/get-embedding-secret-key conn*) 261 | :value))) 262 | 263 | ;; begin normal connection 264 | (def conn (e/connect config*)) 265 | ``` 266 | 267 | #### Create a new db connection 268 | See the example file from `repl_sessions/create_db_conn.clj` 269 | 270 | ``` 271 | ;; Example for Postgres 272 | (def db-conn-name "metabase-db-connection-name") 273 | (def engine "postgres") 274 | (def details {...}) 275 | (setup/create-db! conn db-conn-name engine details) 276 | ``` 277 | 278 | #### Trigger the sync of a db schema and field values 279 | 280 | ``` 281 | (e/trigger-db-fn! conn "example_tenant" :sync_schema) 282 | (e/trigger-db-fn! conn "example_tenant" :rescan_values) 283 | ``` 284 | 285 | #### ID lookup utilities 286 | For human, it is natural to remember the name of an entity, be it a database, 287 | database schema, or a table. On the other hand, inside metabase, these entities are 288 | all represented by numeric IDs. 289 | That is why we also provide a series of ID lookup utilities: 290 | ``` 291 | (find-database ...) ;; get the database entity information which include db-id through database-name 292 | (table-id ...) ;; find out field-id by database-name, schema-name, table-name 293 | (field-id ...) ;; find out field-id by database-name, schema-name, table-name, field-name 294 | (user-id ...) ;; find out user-id by email 295 | (group-id ...) ;; find out group-id by group-name 296 | ``` 297 | 298 | ### Metabase version & related issues 299 | 300 | #### Supported Metabase version 301 | 0.44.0 or later 302 | 303 | #### Pagination 304 | The newest release version of embedkit is developed along with metabase version `0.44.6`. 305 | According to [here](https://github.com/metabase/metabase/wiki/What%27s-new-in-0.40.0-for-Metabase-REST-API-clients), metabase should have `/api/user` and `/api/database` supporting pagination feature. However, real world testing shows that only `/api/user` has the pagination feature. Also, the `total` in the return result of `/api/user` is actually refering to the total number of users rather than the total number of users with respect to the query. 306 | 307 | 308 | ## Contributing 309 | 310 | Everyone has a right to submit patches to embedkit, and thus become a contributor. 311 | 312 | Contributors MUST 313 | 314 | - adhere to the [LambdaIsland Clojure Style Guide](https://nextjournal.com/lambdaisland/clojure-style-guide) 315 | - write patches that solve a problem. Start by stating the problem, then supply a minimal solution. `*` 316 | - agree to license their contributions as MPL 2.0. 317 | - not break the contract with downstream consumers. `**` 318 | - not break the tests. 319 | 320 | Contributors SHOULD 321 | 322 | - update the CHANGELOG and README. 323 | - add tests for new functionality. 324 | 325 | If you submit a pull request that adheres to these rules, then it will almost 326 | certainly be merged immediately. However some things may require more 327 | consideration. If you add new dependencies, or significantly increase the API 328 | surface, then we need to decide if these changes are in line with the project's 329 | goals. In this case you can start by [writing a pitch](https://nextjournal.com/lambdaisland/pitch-template), 330 | and collecting feedback on it. 331 | 332 | `*` This goes for features too, a feature needs to solve a problem. State the problem it solves, then supply a minimal solution. 333 | 334 | `**` As long as this project has not seen a public release (i.e. is not on Clojars) 335 | we may still consider making breaking changes, if there is consensus that the 336 | changes are justified. 337 | 338 | 339 | 340 | ## License 341 | 342 | Copyright © 2021 Arne Brasseur and Contributors 343 | 344 | Licensed under the term of the Mozilla Public License 2.0, see LICENSE. 345 | 346 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:deps 2 | {lambdaisland/open-source {:git/url "https://github.com/lambdaisland/open-source" 3 | :git/sha "2e2449746deceda288c9a001fe48dfe092e47b06"}}} 4 | -------------------------------------------------------------------------------- /bin/kaocha: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | clojure -A:test -m kaocha.runner "$@" 3 | -------------------------------------------------------------------------------- /bin/proj: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (require '[lioss.main :as lioss]) 4 | 5 | (lioss/main 6 | {:license :mpl 7 | :inception-year 2021 8 | :description "Use Metabase as a dashboard engine" 9 | :group-id "com.lambdaisland"}) 10 | 11 | 12 | ;; Local Variables: 13 | ;; mode:clojure 14 | ;; End: 15 | -------------------------------------------------------------------------------- /bin/start_metabase: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | EMBEDKIT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." >/dev/null 2>&1 && pwd )" 4 | 5 | METABASE__VERSION="0.37.9" 6 | METABASE__JAR="${EMBEDKIT_DIR}/.store/metabase-${METABASE__VERSION}.jar" 7 | METABASE__JAR_URL="https://downloads.metabase.com/v${METABASE__VERSION}/metabase.jar" 8 | 9 | if [[ ! -f "${METABASE__JAR}" ]] ; then 10 | echo "Downloading ${METABASE__JAR}" 11 | mkdir -p $(dirname "${METABASE__JAR}") 12 | curl --progress-bar -o "${METABASE__JAR}" "${METABASE__JAR_URL}" 13 | fi 14 | 15 | cd $(dirname "${METABASE__JAR}") 16 | set -x 17 | exec java -jar "${METABASE__JAR}" 18 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | 3 | :deps 4 | {org.clojure/clojure {:mvn/version "1.10.3"} 5 | org.clojure/data.json {:mvn/version "2.3.1"} 6 | buddy/buddy-sign {:mvn/version "3.4.1"} 7 | hato/hato {:mvn/version "0.8.1"} 8 | honeysql/honeysql {:mvn/version "1.0.461"} 9 | io.replikativ/hasch {:mvn/version "0.3.7"} 10 | lambdaisland/uri {:mvn/version "1.4.54"}} 11 | 12 | :aliases 13 | {:dev 14 | {:extra-paths ["dev"] 15 | :extra-deps {djblue/portal {:mvn/version "RELEASE"}}} 16 | 17 | :test 18 | {:extra-paths ["test"] 19 | :extra-deps {lambdaisland/kaocha {:mvn/version "1.0.861"}}}}} 20 | -------------------------------------------------------------------------------- /dev/config.edn: -------------------------------------------------------------------------------- 1 | {:user "admin@example.com" 2 | :password "aqd4ijj4" 3 | :secret-key "6fa6b6600d27ff276d3d0e961b661fb3b082f8b60781e07d11b8325a6e1025c5" 4 | :first-name "Laurence" 5 | :last-name "Chen" 6 | :site-name "My site"} 7 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user) 2 | 3 | (defmacro jit [sym] 4 | `(requiring-resolve '~sym)) 5 | 6 | (def portal-instance (atom nil)) 7 | 8 | (defn portal 9 | "Open a Portal window and register a tap handler for it. The result can be 10 | treated like an atom." 11 | [] 12 | ;; Portal is both an IPersistentMap and an IDeref, which confuses pprint. 13 | (prefer-method @(jit clojure.pprint/simple-dispatch) clojure.lang.IPersistentMap clojure.lang.IDeref) 14 | ;; Portal doesn't recognize records as maps, make them at least datafiable 15 | (extend-protocol clojure.core.protocols/Datafiable 16 | clojure.lang.IRecord 17 | (datafy [r] (into {} r))) 18 | (let [p ((jit portal.api/open) @portal-instance)] 19 | (reset! portal-instance p) 20 | (add-tap (jit portal.api/submit)) 21 | p)) 22 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.lambdaisland 5 | embedkit 6 | 0.0.66 7 | embedkit 8 | Use Metabase as a dashboard engine 9 | https://github.com/lambdaisland/embedkit 10 | 2021 11 | 12 | Lambda Island 13 | https://lambdaisland.com 14 | 15 | 16 | 17 | MPL-2.0 18 | https://www.mozilla.org/media/MPL/2.0/index.txt 19 | 20 | 21 | 22 | https://github.com/lambdaisland/embedkit 23 | scm:git:git://github.com/lambdaisland/embedkit.git 24 | scm:git:ssh://git@github.com/lambdaisland/embedkit.git 25 | 1a3ba244877f6b69be9bfac7d8a2824344b1c5d6 26 | 27 | 28 | 29 | org.clojure 30 | clojure 31 | 1.10.3 32 | 33 | 34 | org.clojure 35 | data.json 36 | 2.3.1 37 | 38 | 39 | buddy 40 | buddy-sign 41 | 3.4.1 42 | 43 | 44 | hato 45 | hato 46 | 0.8.1 47 | 48 | 49 | honeysql 50 | honeysql 51 | 1.0.461 52 | 53 | 54 | io.replikativ 55 | hasch 56 | 0.3.7 57 | 58 | 59 | lambdaisland 60 | uri 61 | 1.4.54 62 | 63 | 64 | 65 | src 66 | 67 | 68 | src 69 | 70 | 71 | resources 72 | 73 | 74 | 75 | 76 | org.apache.maven.plugins 77 | maven-compiler-plugin 78 | 3.8.1 79 | 80 | 81 | org.apache.maven.plugins 82 | maven-jar-plugin 83 | 3.2.0 84 | 85 | 86 | 87 | 1a3ba244877f6b69be9bfac7d8a2824344b1c5d6 88 | 89 | 90 | 91 | 92 | 93 | org.apache.maven.plugins 94 | maven-gpg-plugin 95 | 1.6 96 | 97 | 98 | sign-artifacts 99 | verify 100 | 101 | sign 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | clojars 111 | https://repo.clojars.org/ 112 | 113 | 114 | 115 | 116 | clojars 117 | Clojars repository 118 | https://clojars.org/repo 119 | 120 | 121 | -------------------------------------------------------------------------------- /repl_sessions/create_db_conn.clj: -------------------------------------------------------------------------------- 1 | (ns create-db-conn 2 | (:require [lambdaisland.embedkit :as e] 3 | [lambdaisland.embedkit.setup :as setup])) 4 | 5 | (def conn 6 | (e/connect (read-string (slurp "dev/config.edn")))) 7 | 8 | conn 9 | ;; create the database 10 | (comment 11 | ;; Example for Presto 12 | ;; Presto driver is going to be deprecated in new version of Metabase. 13 | (def db-conn-name "presto-db") 14 | (def engine "presto") 15 | (def details {:host "localhost" 16 | :port 4383 17 | :catalog "analytics" 18 | :user "." 19 | :password "" 20 | :ssl false 21 | :tunnel-enabled false})) 22 | (comment 23 | ;; Example for Postgres 24 | (def db-conn-name "kkk") 25 | (def engine "postgres") 26 | (def details {:host "localhost" 27 | :port 5432 28 | :dbname "example_tenant" 29 | :user (System/getenv "POSTGRESQL__USER") 30 | :password (System/getenv "POSTGRESQL__PASSWORD") 31 | :ssl false 32 | :tunnel-enabled false})) 33 | 34 | (setup/create-db! conn db-conn-name engine details) 35 | -------------------------------------------------------------------------------- /repl_sessions/dashboard_card.clj: -------------------------------------------------------------------------------- 1 | (ns dashboard-card 2 | (:require 3 | [clojure.java.browse :refer [browse-url]] 4 | [clojure.pprint :as pprint] 5 | [clojure.string :as str] 6 | [lambdaisland.embedkit :as e] 7 | [lambdaisland.embedkit.repl :as r] 8 | [lambdaisland.embedkit.watch :as w :refer [watch! unwatch!]])) 9 | 10 | ;; create atom connection 11 | (def conn (atom nil)) 12 | (reset! conn (e/connect (read-string (slurp "dev/config.edn")))) 13 | 14 | (prn "Is conn an Atom" (instance? clojure.lang.Atom conn)) 15 | 16 | ;; find database by name 17 | (def db (e/find-database conn "Sample Database")) 18 | (:id db) 19 | 20 | ;; populate cache 21 | (e/populate-cache conn) 22 | 23 | ;; create card 24 | (def card (e/native-card {:name "order card" 25 | :variables {:start_date {:editable? true 26 | :type "date"} 27 | :end_date {:editable? true 28 | :type "date"}} 29 | :database (:id db) 30 | :sql "SELECT * FROM orders [[WHERE created_at BETWEEN {{start_date}} AND {{end_date}}]]"})) 31 | card 32 | (r/browse! (e/find-or-create! conn card)) 33 | 34 | ;; create dashboard with dashboard-card 35 | (let [card1 (e/native-card {:name "order card" 36 | :database (:id db) 37 | :sql "SELECT * FROM orders"}) 38 | card2 (e/native-card {:name "invoice card" 39 | :database (:id db) 40 | :sql "SELECT * FROM invoices"}) 41 | dash (e/find-or-create! conn (e/dashboard {:name "Sample DB dashboard" 42 | :cards [{:card card1 43 | :x 0 :y 0 44 | :width 5 :height 5} 45 | {:card card2 46 | :x 5 :y 0 47 | :width 5 :height 5}]}))] 48 | (browse-url (str (e/embed-url conn dash)))) 49 | 50 | (comment 51 | (let [card1 (e/native-card {:name "order card" 52 | :database (:id db) 53 | :sql "SELECT id FROM orders"}) 54 | card2 (e/native-card {:name "invoice card" 55 | :database (:id db) 56 | :sql "SELECT id FROM invoices"}) 57 | ] 58 | (try 59 | (e/find-or-create! conn (e/dashboard {:name "Sample DB 3 dashboard" 60 | :cards [{:card card1 61 | :x 0 :y 0 62 | :width 5 :height 5} 63 | {:card card2 64 | :x 5 :y 0 65 | :width 5 :height 5}]})) 66 | (catch Exception e 67 | (prn e) 68 | (prn (ex-data e))) 69 | ) 70 | ) 71 | (browse-url (str (e/embed-url conn dash))))) 72 | 73 | -------------------------------------------------------------------------------- /repl_sessions/demo.clj: -------------------------------------------------------------------------------- 1 | (ns demo 2 | (:require [clojure.java.browse :refer [browse-url]] 3 | [clojure.pprint :as pprint] 4 | [clojure.string :as str] 5 | [lambdaisland.embedkit :as e] 6 | [lambdaisland.embedkit.repl :as r] 7 | [lambdaisland.embedkit.watch :as w :refer [watch! unwatch!]])) 8 | 9 | (def conn (e/connect (read-string (slurp "dev/config.edn")))) 10 | 11 | conn 12 | 13 | (def db (e/find-database conn "datomic")) 14 | 15 | (:id db) 16 | 17 | 18 | 19 | 20 | (def card (e/native-card {:name "my card" 21 | :database (:id db) 22 | :sql "SELECT * FROM onze.journal_entry_line"})) 23 | 24 | card 25 | 26 | (r/browse! (e/find-or-create! conn card)) 27 | 28 | 29 | 30 | 31 | (keys (:by-hash @(:cache conn))) 32 | 33 | (e/populate-cache conn) 34 | 35 | 36 | (def bar-card (e/bar-chart card {:x-axis ["description"] 37 | :y-axis ["amount"]})) 38 | 39 | (->> bar-card 40 | (e/find-or-create! conn) 41 | (r/browse!)) 42 | 43 | 44 | 45 | 46 | (r/browse! (e/find-or-create! conn (e/dashboard {:name "My dashboard"}))) 47 | 48 | (let [card (e/find-or-create! conn card) 49 | dashboard (e/find-or-create! conn (e/dashboard {:name "My dashboard"}))] 50 | (e/find-or-create! conn (e/dashboard-card {:card card 51 | :dashboard dashboard}))) 52 | 53 | 54 | 55 | (def dashboard (e/dashboard {:name "My dashboard" 56 | :cards [{:card card 57 | :width 5 :height 5} 58 | {:card bar-card 59 | :x 5 :y 2}]})) 60 | 61 | (r/browse! (e/find-or-create! conn dashboard)) 62 | 63 | (browse-url (e/embed-url conn (e/find-or-create! conn dashboard))) 64 | 65 | 66 | 67 | 68 | (def biggest-accounts 69 | (-> (e/native-card {:name "Biggest accounts" 70 | :database 2 71 | :variables {:company_legal_code {:editable? true}} 72 | :sql {:select ["account__name" "SUM(amount) AS total"] 73 | :from ["onze.journal_entry_line"] 74 | :where [:= "company.legal_code" "{{company_legal_code}}"] 75 | :join ["onze.journal_entry_x_journal_entry_lines" [:= "journal_entry_line.db__id" "journal_entry_x_journal_entry_lines.journal_entry_lines"] 76 | "onze.ledger_x_journal_entries" [:= "journal_entry_x_journal_entry_lines.db__id" "ledger_x_journal_entries.journal_entries"] 77 | "onze.fiscal_year_x_ledgers" [:= "ledger_x_journal_entries.db__id" "fiscal_year_x_ledgers.ledgers"] 78 | "onze.company_x_fiscal_years" [:= "fiscal_year_x_ledgers.db__id" "company_x_fiscal_years.fiscal_years"] 79 | "tnt.company" [:= "company_x_fiscal_years.id" "company.uuid"]] 80 | :group-by ["account__name"] 81 | :order-by [["total" :desc]]}}) 82 | (e/bar-chart {:x-axis ["account__name"] 83 | :y-axis ["total"]}))) 84 | 85 | 86 | (r/browse! (e/find-or-create! conn biggest-accounts)) 87 | 88 | (def var-dashboard (e/dashboard {:name "Var dashboard" 89 | :cards [{:card biggest-accounts}]})) 90 | 91 | 92 | (r/browse! (e/find-or-create! conn var-dashboard)) 93 | 94 | (def iframe-url (e/embed-url conn (e/find-or-create! conn var-dashboard))) 95 | 96 | (browse-url iframe-url) 97 | -------------------------------------------------------------------------------- /repl_sessions/find_db.clj: -------------------------------------------------------------------------------- 1 | (ns find-db 2 | (:require [lambdaisland.embedkit :as e])) 3 | 4 | (def conn 5 | (e/connect (read-string (slurp "dev/config.edn")))) 6 | 7 | (e/find-database conn "example_tenant") 8 | 9 | (:id (e/find-database conn "example_tenant")) 10 | 11 | (e/mb-get conn [:database]) 12 | -------------------------------------------------------------------------------- /repl_sessions/init.clj: -------------------------------------------------------------------------------- 1 | (ns init 2 | (:require [lambdaisland.embedkit :as e] 3 | [lambdaisland.embedkit.repl :as r] 4 | [lambdaisland.embedkit.setup :as setup])) 5 | 6 | (def config 7 | (select-keys (read-string (slurp "dev/config.edn")) 8 | [:user :password])) 9 | (comment 10 | ;; first-name, last-name, site-name are optional parameters 11 | (def config 12 | (select-keys (read-string (slurp "dev/config.edn")) 13 | [:user :password 14 | :first-name :last-name 15 | :site-name]))) 16 | 17 | ;; create admin user and enable embedded 18 | 19 | 20 | (setup/init-metabase! config) 21 | 22 | ;; setup embedding secret key 23 | (def conn* (e/connect config)) 24 | (e/mb-put conn* 25 | [:setting :embedding-secret-key] 26 | {:form-params {:value "6fa6b6600d27ff276d3d0e961b661fb3b082f8b60781e07d11b8325a6e1025c5"}}) 27 | 28 | (comment 29 | ;; for debugging purpose 30 | ;; show all the setting kv pairs 31 | (setup/get-metabase-setting! conn*)) 32 | 33 | ;; get the embedding secret key 34 | (def config* (assoc config 35 | :secret-key (get 36 | (setup/get-embedding-secret-key conn*) 37 | :value))) 38 | 39 | ;; begin normal connection 40 | (def conn (e/connect config*)) 41 | -------------------------------------------------------------------------------- /repl_sessions/lookup_id.clj: -------------------------------------------------------------------------------- 1 | (ns lookup-id 2 | (:require [clojure.pprint :as pprint] 3 | [clojure.string :as str] 4 | [lambdaisland.embedkit :as e])) 5 | 6 | (def conn 7 | (e/connect (read-string (slurp "dev/config.edn")))) 8 | 9 | conn 10 | 11 | (def db (e/find-database conn "example_tenant")) 12 | ;; 13 | (e/fetch-database-fields conn (:id db)) 14 | (e/table-id conn "example_tenant" "enzo" "Denormalised General Ledger entries - Draft and Posted") 15 | (e/field-id conn "example_tenant" "enzo" "Denormalised General Ledger entries - Draft and Posted" "document_date") 16 | 17 | (get-in 18 | @(:cache conn) 19 | [:databases "example_tenant" :schemas "enzo" :tables "Denormalised General Ledger entries - Draft and Posted"]) 20 | 21 | (e/fetch-all-users conn) 22 | (e/fetch-users conn :all) 23 | (e/user-id conn "admin@example.com") 24 | 25 | (e/fetch-all-groups conn) 26 | (e/group-id conn "Administrators") 27 | 28 | (e/trigger-db-fn! conn "example_tenant" :sync_schema) 29 | (e/trigger-db-fn! conn "example_tenant" :rescan_values) 30 | -------------------------------------------------------------------------------- /repl_sessions/pagination.clj: -------------------------------------------------------------------------------- 1 | (ns pagination 2 | (:require [lambdaisland.embedkit :as e])) 3 | 4 | (def conn 5 | (e/connect (read-string (slurp "dev/config.edn")))) 6 | 7 | (e/fetch-users conn :all) 8 | (e/fetch-users conn :active) 9 | 10 | (e/find-database conn "example_tenant") 11 | 12 | (comment 13 | (get-in (e/mb-get conn [:database] 14 | {:query-params {:limit 1 15 | :offset 1}}) 16 | [:body]) 17 | 18 | (get-in (e/mb-get conn [:user] 19 | {:query-params {:limit 1 20 | :offset 0}}) 21 | [:body]) 22 | 23 | (get-in (e/mb-get conn [:user] 24 | {:query-params {:limit 1 25 | :offset 1}}) 26 | [:body])) 27 | -------------------------------------------------------------------------------- /repl_sessions/session.clj: -------------------------------------------------------------------------------- 1 | (ns repl-sessions.session 2 | (:require [clojure.java.browse :refer [browse-url]] 3 | [clojure.pprint :as pprint] 4 | [clojure.string :as str] 5 | [lambdaisland.embedkit :as e] 6 | [lambdaisland.embedkit.repl :as r :refer [browse!]] 7 | [lambdaisland.embedkit.watch :as w :refer [watch! unwatch!]])) 8 | 9 | ;; Start embedkit connection, this will grab a token from Metabase and hold it 10 | ;; for later requests. It also instantiates a Hato (Java 11 HttpClient wrapper) 11 | ;; instance, and keeps a cache for certain requests. 12 | (def conn (e/connect (merge 13 | (read-string (slurp "dev/config.edn")) 14 | {#_#_:middleware (conj hato.middleware/default-middleware 15 | r/print-request-mw)}))) 16 | 17 | (e/populate-cache conn) 18 | 19 | ;; We'll need to know the numeric id of our database in Metabase. This is an 20 | ;; example of something that gets cached. 21 | (def my-db (e/find-database conn "datomic")) 22 | (:id my-db) 23 | ;; => 2 24 | 25 | ;; Simple example to warm up 26 | (->> (e/native-card {:name "my card" 27 | :database (:id my-db) 28 | :sql "SELECT * FROM onze.company"}) 29 | (e/find-or-create! conn) 30 | browse!) 31 | 32 | ;; Now let's decorate this, the bar-chart function changes the visualiztion from 33 | ;; a table to a bar chart. You can configure certain options, like which 34 | ;; dimensions to show on x and y axis. 35 | ;; 36 | ;; I'm using HoneySQL syntax here, which I quite like so you don't have to write 37 | ;; SQL by mashing together strings, but you can create the SQL however you like. 38 | 39 | (defn biggest-accounts [] 40 | (-> (e/native-card {:name "account bars var" 41 | :database 2 42 | :sql {:select ["account__name" "SUM(amount) AS total"] 43 | :from ["onze.journal_entry_line"] 44 | :group-by ["account__name"] 45 | :order-by [["total" :desc]]}}) 46 | (e/bar-chart {:x-axis ["account__name"] 47 | :y-axis ["total"]}))) 48 | 49 | (->> (biggest-accounts) 50 | (e/create! conn) 51 | (browse!)) 52 | 53 | (defn accounts-payable [] 54 | (-> (e/native-card {:name "Accounts payable - Top 10" 55 | :database (:id my-db) 56 | :sql {:select ["onze.account.number" 57 | "onze.account.name" 58 | "SUM(IF(flow = (SELECT db__id FROM onze.db__idents WHERE ident = ':flow/credit'), amount, (amount * -1))) AS balance"] 59 | :from ["onze.journal_entry_line"] 60 | :join ["onze.account" 61 | [:= "onze.account.db__id" "onze.journal_entry_line.account"]] 62 | :where [:= "onze.account.subtype" 63 | {:select ["db__id"] 64 | :from ["onze.db__idents"] 65 | :where [:= "ident" "':account.subtype/accounts-payable'"]}] 66 | :group-by ["account.number" "account.name"] 67 | :order-by [["balance" :desc]] 68 | :limit 10}}) 69 | (e/bar-chart {:x-axis ["name"] :y-axis ["balance"]}))) 70 | 71 | ;; More complex query, show top accounts payable 72 | (->> (accounts-payable) 73 | (e/create! conn) 74 | (browse!)) 75 | 76 | (watch! [:card 48]) 77 | (watch! [:dashboard 1]) 78 | 79 | (let [card1 (e/create! conn (accounts-payable)) 80 | card2 (e/create! conn (biggest-accounts)) 81 | dash (e/create! conn (e/dashboard {:name "Foo"}))] 82 | (e/create! conn (e/dashboard-card {:card card1 :dashboard dash 83 | :x 0 :y 0 :width 5 :height 5})) 84 | (e/create! conn (e/dashboard-card {:card card2 :dashboard dash 85 | :x 5 :y 0 :width 5 :height 5})) 86 | (e/enable-embedding! conn dash) 87 | (browse-url (str (e/embed-url conn dash)))) 88 | 89 | (->> {:name "Foo" 90 | :cards [{:card (accounts-payable) 91 | :width 7 :height 5} 92 | {:card (biggest-accounts-company) 93 | :x 7 94 | :width 7 :height 5}]} 95 | e/dashboard 96 | (e/find-or-create! conn) 97 | (e/embed-url conn) 98 | str 99 | browse-url 100 | time) 101 | 102 | (->> (-> (e/native-card {:name "my card" 103 | :database (:id my-db) 104 | :sql "SELECT * FROM onze.company"}) 105 | 106 | ) 107 | (e/find-or-create! conn) 108 | browse!) 109 | 110 | @(:cache conn) 111 | 112 | (e/mb-get conn [:card 110]) 113 | 114 | (doseq [card (:body (e/mb-get conn [:card 111]))] 115 | (when-let [hash (get-in card [:visualization_settings :embedkit.hash])] 116 | )) 117 | 118 | (r/delete-all-cards! conn) 119 | (r/delete-all-dashboards! conn) 120 | (reset! (:cache conn) {}) 121 | 122 | (defn biggest-accounts-company [] 123 | (-> (e/native-card {:name "account bars var" 124 | :database 2 125 | :variables {:company_legal_code {}} 126 | :sql {:select ["account__name" "SUM(amount) AS total"] 127 | :from ["onze.journal_entry_line"] 128 | :where [:= "company.legal_code" "{{company_legal_code}}"] 129 | :join ["onze.journal_entry_x_journal_entry_lines" [:= "journal_entry_line.db__id" "journal_entry_x_journal_entry_lines.journal_entry_lines"] 130 | "onze.ledger_x_journal_entries" [:= "journal_entry_x_journal_entry_lines.db__id" "ledger_x_journal_entries.journal_entries"] 131 | "onze.fiscal_year_x_ledgers" [:= "ledger_x_journal_entries.db__id" "fiscal_year_x_ledgers.ledgers"] 132 | "onze.company_x_fiscal_years" [:= "fiscal_year_x_ledgers.db__id" "company_x_fiscal_years.fiscal_years"] 133 | "tnt.company" [:= "company_x_fiscal_years.id" "company.uuid"]] 134 | :group-by ["account__name"] 135 | :order-by [["total" :desc]]}}) 136 | (e/bar-chart {:x-axis ["account__name"] 137 | :y-axis ["total"]}))) 138 | 139 | (->> {:name "My dashboard" 140 | :cards [#_{:card (accounts-payable) 141 | :width 7 :height 5} 142 | {:card (biggest-accounts-company) 143 | :x 7 144 | :width 7 :height 5}]} 145 | e/dashboard 146 | (e/find-or-create! conn) 147 | (#(e/embed-url conn % {:variables {:company_legal_code "BRE"}})) 148 | str 149 | browse-url 150 | #_time) 151 | 152 | (watch! conn [:dashboard ]) 153 | (browse! ddd) 154 | (def after (:body (e/mb-get conn [:dashboard 62]))) 155 | 156 | (dissoc before :ordered_cards) 157 | (dissoc after :ordered_cards) 158 | 159 | (w/only-changes before after) 160 | 161 | (map :parameter_mappings (:ordered_cards before)) 162 | (map :parameter_mappings (:ordered_cards after)) 163 | 164 | (browse-url (e/embed-url conn 165 | (:body (e/mb-get conn [:dashboard 56])) 166 | {:variables {:id "BRE"}})) 167 | 168 | (reset! (:cache conn) {}) 169 | 170 | (require '[lambdaisland.embedkit :as e] 171 | '[lambdaisland.embedkit.repl :as r]) 172 | 173 | (def conn (e/connect {:user "admin@example.com" :password "..." :secret-key "..."})) 174 | 175 | (def db (e/find-database "orders")) 176 | 177 | (e/find-or-create! 178 | conn 179 | (e/dashboard {:name "My sales dashboard" 180 | :cards [{:card (my-card-fn) 181 | :x 5 :y 0 182 | :width 12 :height 10}]})) 183 | 184 | ;; Open the dashboard in the browser, REPL helper for local testing 185 | (r/browse! dashboard) 186 | 187 | ;; Get an embed-url that you can use in an iframe 188 | (e/embed-url conn dashboard) 189 | 190 | (e/native-card {:name "Monthly revenue" 191 | :database {:id 2} 192 | :sql {:select ["month" "SUM(amount) AS total"] 193 | :from ["orders"] 194 | :group-by ["month"] 195 | :order-by ["month"]}}) 196 | -------------------------------------------------------------------------------- /src/lambdaisland/embedkit.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.embedkit 2 | (:require [buddy.sign.jwt :as jwt] 3 | [clojure.data.json :as json] 4 | [clojure.java.io :as io] 5 | [clojure.string :as str] 6 | [clojure.walk :as walk] 7 | [hasch.core :as hasch] 8 | [hasch.hex :as hasch-hex] 9 | [hato.client :as http] 10 | [hato.middleware :as hato-mw] 11 | [honeysql.core :as honey] 12 | [lambdaisland.uri :as uri]) 13 | (:import (java.util UUID))) 14 | 15 | (set! *warn-on-reflection* true) 16 | (set! *unchecked-math* :warn-on-boxed) 17 | 18 | (defn- hex->base36 [^String hex] 19 | (.toString (BigInteger. hex 16) 36)) 20 | 21 | (defn- edn->hash [edn] 22 | (let [edn (walk/postwalk (fn [x] 23 | (if (and x (str/starts-with? (.getName ^Class (class x)) "java.time")) 24 | (str x) 25 | x)) 26 | edn)] 27 | (hex->base36 (hasch-hex/encode (take 32 (hasch/edn-hash edn)))))) 28 | 29 | (defn- as-str [v] 30 | (if (instance? clojure.lang.Named v) 31 | (name v) 32 | (str v))) 33 | 34 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 35 | ;; Plumbing 36 | 37 | ;; Use clojure.data.json instead of Cheshire 38 | (defmethod hato-mw/coerce-form-params :application/json 39 | [{:keys [form-params json-opts]}] 40 | (apply json/write-str form-params (mapcat identity json-opts))) 41 | 42 | (defmethod hato-mw/coerce-response-body :json [{:keys [coerce] :as req} 43 | {:keys [body status] :as resp}] 44 | (let [^String charset (or (-> resp :content-type-params :charset) "UTF-8")] 45 | (cond 46 | (and (hato-mw/unexceptional-status? status) 47 | (or (nil? coerce) (= coerce :unexceptional))) 48 | (with-open [r (io/reader body :encoding charset)] 49 | (assoc resp :body (json/read r 50 | :key-fn keyword 51 | :eof-error? false))) 52 | 53 | (= coerce :always) 54 | (with-open [r (io/reader body :encoding charset)] 55 | (assoc resp :body (json/read r 56 | :key-fn keyword 57 | :eof-error? false))) 58 | 59 | (and (not (hato-mw/unexceptional-status? status)) (= coerce :exceptional)) 60 | (with-open [r (io/reader body :encoding charset)] 61 | (assoc resp :body (json/read r 62 | :key-fn keyword 63 | :eof-error? false))) 64 | 65 | :else (assoc resp :body (slurp body :encoding charset))))) 66 | 67 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 68 | ;; Connection 69 | 70 | (defprotocol IConnection 71 | (path-url [_ p]) 72 | (request [_ method url opts]) 73 | (conn-cache [_]) 74 | (secret-key [_])) 75 | 76 | (defrecord Connection [client endpoint token cache secret-key middleware] 77 | IConnection 78 | (path-url [this p] 79 | (if (vector? p) 80 | (recur (str (if (= :embed (first p)) "/" "/api/") 81 | (str/join "/" (map as-str p)))) 82 | (str endpoint p))) 83 | (request [this method path opts] 84 | (-> {:http-client client 85 | :request-method method 86 | :url (path-url this path) 87 | :headers {"x-metabase-session" token} 88 | :content-type :json 89 | :as :json} 90 | (cond-> middleware (assoc :middleware middleware)) 91 | (into opts))) 92 | (conn-cache [this] 93 | (:cache this)) 94 | (secret-key [this] 95 | (:secret-key this))) 96 | 97 | (extend-protocol IConnection 98 | clojure.lang.Atom 99 | (path-url [this p] 100 | (path-url @this p)) 101 | (request [this method path opts] 102 | (request @this method path opts)) 103 | (conn-cache [this] 104 | (conn-cache @this)) 105 | (secret-key [this] 106 | (secret-key @this))) 107 | 108 | (defn connect 109 | "Create a connection to the Metabase API. This does an authentication call to 110 | get a token that is used subsequently. Returns a Connection which wraps a Hato 111 | HttpClient instance, endpoint details, the token, and a cache atom to reduce 112 | requests. 113 | 114 | Defaults to connecting to `http://localhost:3000` 115 | 116 | To create embed urls a `:secret-key` is also needed, to be found in the 117 | Metabase embed settings." 118 | [{:keys [user password 119 | ;; optional 120 | host port https? hato-client connect-timeout 121 | secret-key middleware] 122 | :or {connect-timeout 10000 123 | https? false 124 | host "localhost" 125 | port 3000} 126 | :as conn-opts}] 127 | (when-not secret-key 128 | (binding [*out* *err*] 129 | (println "WARNING: no :secret-key provided to" `connect ", generating embed urls will be disabled."))) 130 | (let [client (or hato-client 131 | (http/build-http-client 132 | {:connect-timeout connect-timeout 133 | :redirect-policy :always})) 134 | endpoint (str "http" (when https? "s") "://" host (when port (str ":" port))) 135 | token (:id (:body (http/post (str endpoint "/api/session") 136 | {:http-client client 137 | :form-params {"username" user 138 | "password" password} 139 | :content-type :json 140 | :as :json})))] 141 | (map->Connection (assoc 142 | conn-opts 143 | :client client 144 | :endpoint endpoint 145 | :token token 146 | :cache (atom {}) 147 | :secret-key secret-key 148 | :middleware middleware)))) 149 | 150 | (defn- do-request [req] 151 | (vary-meta (http/request req) 152 | assoc 153 | ::request req)) 154 | 155 | (defmacro with-refresh-auth [conn & body] 156 | `((fn retry# [conn# retries#] 157 | (Thread/sleep (* 1000 retries# retries#)) ; backoff 158 | (try 159 | ~@body 160 | (catch clojure.lang.ExceptionInfo e# 161 | (if (and (= 401 (:status (ex-data e#))) 162 | (instance? clojure.lang.Atom conn#) 163 | (< retries# 4)) 164 | (do 165 | (reset! conn# (connect @conn#)) 166 | (retry# conn# (inc retries#))) 167 | (throw e#))))) 168 | ~conn 169 | 0)) 170 | 171 | (defn mb-request [verb conn path opts] 172 | (with-refresh-auth conn 173 | (do-request (request conn verb path opts)))) 174 | 175 | (defn mb-get 176 | "Perform a GET request to the Metabase API. Path can be a string or a vector. 177 | 178 | (mb-get conn \"/api/cards/6\") 179 | (mb-get conn [:cards 6])" 180 | [conn path & [opts]] 181 | (mb-request :get conn path opts)) 182 | 183 | (defn mb-post 184 | "Perform a POST request to the Metabase API" 185 | [conn path & [opts]] 186 | (mb-request :post conn path opts)) 187 | 188 | (defn mb-put 189 | "Perform a PUT request to the Metabase API" 190 | [conn path & [opts]] 191 | (mb-request :put conn path opts)) 192 | 193 | (defn mb-delete 194 | "Perform a DELETE request to the Metabase API" 195 | [conn path & [opts]] 196 | (mb-request :delete conn path opts)) 197 | 198 | (defn embed-payload-url 199 | "Sign the payload with the `:secret-key` from the connection, and use it to 200 | request an embed-url." 201 | [conn payload {:keys [bordered? titled? filters] 202 | :or {bordered? false 203 | titled? false 204 | filters {}} 205 | :as opts}] 206 | (when-let [key (secret-key conn)] 207 | (let [token (jwt/sign payload key)] 208 | (-> (uri/uri (path-url conn [:embed :dashboard token])) 209 | (assoc :fragment (uri/map->query-string 210 | (assoc filters 211 | :bordered (str bordered?) 212 | :titled (str titled?)))) 213 | str)))) 214 | 215 | (defn embed-url 216 | "Get an embedding URL for a given resource." 217 | ([conn resource] 218 | (embed-url conn resource nil)) 219 | ([conn resource {:keys [variables bordered? titled? filters ^long timeout] 220 | :or {timeout 3600 221 | variables {}} 222 | :as opts}] 223 | (let [{::keys [type] :keys [id] 224 | :or {type :dashboard}} (if (map? resource) resource {:id resource})] 225 | (embed-payload-url conn 226 | {:resource {type id} 227 | :params variables 228 | :exp (+ (long (/ (System/currentTimeMillis) 1000)) 229 | timeout)} 230 | opts)))) 231 | 232 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 233 | ;; Helpers 234 | 235 | (defn- format-sql [sql] 236 | (first (honey/format sql :parameterizer :none))) 237 | 238 | (defmacro with-cache [conn path & body] 239 | `(or (get-in @(conn-cache ~conn) ~path) 240 | (let [res# (do ~@body)] 241 | (swap! (conn-cache ~conn) assoc-in ~path res#) 242 | res#))) 243 | 244 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 245 | ;; Pagination 246 | 247 | (def default-pagination-size 200) 248 | 249 | (defn wrap-paginate 250 | "Decorator of the normal `mb-get` function 251 | This decorator will create a new version of `mb-get` which will collect all 252 | the results of each page and return the concatenated list of `[:body :data]` 253 | 254 | Note: `error-logger` can be nil" 255 | ([f page-size] 256 | (wrap-paginate f page-size nil)) 257 | ([f page-size error-logger] 258 | {:pre [(pos-int? page-size)]} 259 | (fn paginate 260 | ([conn path] 261 | (paginate conn path {:query-params {}})) 262 | ([conn path opts] 263 | (let [limit page-size 264 | opts* (update opts 265 | :query-params #(conj % [:limit limit] [:offset 0])) 266 | resp* (f conn path opts*) 267 | total (get-in resp* [:body :total]) 268 | lazy-f (fn lazy-f [{:keys [status] :as resp} offset] 269 | (if (= status 200) 270 | (lazy-cat 271 | (get-in resp [:body :data]) 272 | (when (<= (* (inc offset) limit) total) 273 | (lazy-f 274 | (f conn path (assoc-in opts* [:query-params :offset] offset)) (inc offset)))) 275 | (when error-logger 276 | (error-logger resp))))] 277 | (lazy-f resp* 1)))))) 278 | 279 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 280 | ;; Database operations 281 | 282 | (defn find-database 283 | "Get a Database resource by name. This means fetching all databases, but the 284 | result is cached, so we only re-fetch if the given db-name is not yet present 285 | in the cache. 286 | 287 | Mainly used for finding the numeric id of the given Database." 288 | [conn db-name] 289 | (let [path [:databases db-name]] 290 | (or (get-in @(conn-cache conn) path) 291 | (do 292 | (swap! (conn-cache conn) 293 | update 294 | :databases 295 | (fnil into {}) 296 | (doto (map (juxt :name identity) 297 | (get-in (mb-get conn [:database]) [:body :data])))) 298 | (get-in @(conn-cache conn) path))))) 299 | 300 | (defn fetch-database-fields 301 | "Get all tables/fields for a given db-id. Always does a request." 302 | [client db-id] 303 | (let [tables (-> client 304 | (mb-get [:database db-id] 305 | {:query-params {:include "tables.fields"}}) 306 | (get-in [:body :tables])) 307 | field-index (mapcat (fn [{:keys [schema name fields]}] 308 | (map (fn [{:keys [id] field-name :name}] 309 | [schema name field-name id]) 310 | fields)) 311 | tables) 312 | table-index (map (juxt :schema :name identity) tables)] 313 | {:field-index field-index 314 | :table-index table-index})) 315 | 316 | (defn table-id 317 | "Find the numeric id of a given table in a database/schema. Leverages the 318 | cache." 319 | [conn db-name schema table] 320 | (let [path [:databases db-name 321 | :schemas schema 322 | :tables table 323 | :id]] 324 | (if-let [id (get-in @(conn-cache conn) path)] 325 | id 326 | (let [db-id (:id (find-database conn db-name)) 327 | tables (:table-index (fetch-database-fields conn db-id))] 328 | (swap! (conn-cache conn) 329 | (fn [cache] 330 | (reduce (fn [c [schema-name table-name table-entity]] 331 | (assoc-in c 332 | [:databases db-name 333 | :schemas schema-name 334 | :tables table-name 335 | :id] (:id table-entity))) 336 | cache tables))) 337 | (get-in @(conn-cache conn) path))))) 338 | 339 | (defn field-id 340 | "Find the numeric id of a given field in a database/schema/table. Leverages the 341 | cache." 342 | [conn db-name schema table field] 343 | (let [path [:databases db-name 344 | :schemas schema 345 | :tables table 346 | :fields field 347 | :id]] 348 | (if-let [id (get-in @(conn-cache conn) path)] 349 | id 350 | (let [db-id (:id (find-database conn db-name)) 351 | fields (:field-index (fetch-database-fields conn db-id))] 352 | (swap! (conn-cache conn) 353 | (fn [cache] 354 | (reduce (fn [c [s t f id]] 355 | (assoc-in c 356 | [:databases db-name 357 | :schemas s 358 | :tables t 359 | :fields f 360 | :id] id)) 361 | cache fields))) 362 | (get-in @(conn-cache conn) path))))) 363 | 364 | (defn fetch-all-users 365 | "Get users. Always does a request." 366 | [client] 367 | (let [query-opts {:include_deactivated "true"} 368 | f (wrap-paginate mb-get default-pagination-size) 369 | user-list (-> client 370 | (f [:user] 371 | {:query-params query-opts}))] 372 | (vec user-list))) 373 | 374 | (defn fetch-users 375 | "Get users: 376 | 377 | When option is :all, then get all the users. Useful for getting all the users. 378 | When option is :active, then get only the active users." 379 | [client option] 380 | (let [query-opts (case option 381 | :all {:include_deactivated "true"} 382 | :active {}) 383 | f (wrap-paginate mb-get default-pagination-size) 384 | user-list (-> client 385 | (f [:user] 386 | {:query-params query-opts}))] 387 | user-list)) 388 | 389 | (defn user-id 390 | "Find the numeric id of a given user email Leverages the cache." 391 | [conn email] 392 | (let [path [:user-email email 393 | :id]] 394 | (if-let [id (get-in @(conn-cache conn) path)] 395 | id 396 | (let [users (fetch-users conn :all)] 397 | (swap! (conn-cache conn) 398 | (fn [cache] 399 | (reduce (fn [c {:keys [email id]}] 400 | (assoc-in c 401 | [:user-email email 402 | :id] id)) 403 | cache users))) 404 | (get-in @(conn-cache conn) path))))) 405 | 406 | (defn fetch-all-groups 407 | "Get groups. Always does a request." 408 | [client] 409 | (let [group (-> client 410 | (mb-get [:permissions :group]) 411 | (get-in [:body]))] 412 | group)) 413 | 414 | (defn group-id 415 | "Find the numeric id of a given group name. Leverages the cache." 416 | [conn name] 417 | (let [path [:group-name name 418 | :id]] 419 | (if-let [id (get-in @(conn-cache conn) path)] 420 | id 421 | (let [groups (fetch-all-groups conn)] 422 | (swap! (conn-cache conn) 423 | (fn [cache] 424 | (reduce (fn [c {:keys [name id]}] 425 | (assoc-in c 426 | [:group-name name 427 | :id] id)) 428 | cache groups))) 429 | (get-in @(conn-cache conn) path))))) 430 | 431 | (defn trigger-db-fn! 432 | "When success, return ... " 433 | [conn db-name db-fn-link] 434 | {:pre [(or (#{"sync_schema" "rescan_values"} db-fn-link) 435 | (#{:sync_schema :rescan_values} db-fn-link))]} 436 | (let [db-id (:id (find-database conn db-name)) 437 | resp (-> conn 438 | (mb-post [:database db-id (keyword db-fn-link)]) 439 | (get-in [:body]))] 440 | resp)) 441 | 442 | (def embedkit-keys 443 | "Keys that we add interally to entity maps. Generally we deal with data exactly 444 | as the Metabase API expects it and returns, but we add these namespaced keys 445 | for additional bookkeeping. " 446 | [;; The entity type: :card, :dashboard, :dashboard-card 447 | ::type 448 | ;; For a dashboard-card, the dashboard to add it to 449 | ::dashboard-id 450 | ;; For a dashboard-card, the card that it links to 451 | ::card 452 | ;; For a dashboard, the dashboard-cards it should contain 453 | ::dashboard-cards 454 | ;; For a card, its variables (map from keyword to options map) 455 | ::variables]) 456 | 457 | (defn- response-body [response] 458 | (vary-meta (:body response) 459 | assoc 460 | ::request (::request (meta response)) 461 | ::response response)) 462 | 463 | (defn- add-hash [entity hash] 464 | (case (::type entity) 465 | :card 466 | (assoc-in entity [:visualization_settings :embedkit.hash] hash) 467 | :dashboard 468 | (assoc entity :description hash) 469 | entity)) 470 | 471 | (defn- strip-embedkit-keys [entity] 472 | (apply dissoc entity embedkit-keys)) 473 | 474 | (defn- restore-embedkit-keys [response entity] 475 | (merge response (select-keys entity embedkit-keys))) 476 | 477 | (defn resource-path 478 | "Get the API path (in vector representation) for a given resource." 479 | [{::keys [type dashboard-id] :keys [id]}] 480 | (cond-> (case type 481 | :card [:card] 482 | :dashboard [:dashboard] 483 | :dashboard-card [:dashboard dashboard-id :cards]) 484 | id (conj id))) 485 | 486 | (defn create-one! 487 | "Create an entity in Metabase, based on the EDN description. This is a low level 488 | operation, which bypasses caching, and deals with a single entity at a time. 489 | The recommended API is [[find-or-create!]], which handles caching, and 490 | creating multiple related entities. 491 | 492 | `entity` is the result of one of the data definition functions 493 | like [[card]], [[dashboard]], etc." 494 | [conn entity] 495 | (-> (mb-post conn 496 | (resource-path entity) 497 | {:form-params (strip-embedkit-keys entity)}) 498 | (response-body) 499 | (restore-embedkit-keys entity))) 500 | 501 | (defn populate-cache 502 | "Fetch all existing cards and databases from Metabase, and add them to the 503 | per-connection in-memory content addressed cache. Do this after creating your 504 | connection but before calling [[find-or-create!]], to limit the number of 505 | duplicate entities that are created. With a warmed up cache you can safely 506 | call [[find-or-create!]] repeatedly with the same arguments without fear of 507 | causing an explosion of entities in Metabase." 508 | [conn] 509 | (doseq [card (:body (mb-get conn [:card]))] 510 | (when-let [hash (get-in card [:visualization_settings :embedkit.hash])] 511 | (swap! (conn-cache conn) assoc-in [:by-hash hash] (assoc card ::type :card)))) 512 | 513 | (doseq [db (:body (mb-get conn [:dashboard]))] 514 | (when-let [hash (:description db)] 515 | (swap! (conn-cache conn) assoc-in [:by-hash hash] (assoc db ::type :dashboard))))) 516 | 517 | (defn- find-or-create-one! 518 | [conn entity] 519 | (let [hash (edn->hash entity)] 520 | (with-cache conn [:by-hash hash] 521 | (create-one! conn (add-hash entity hash))))) 522 | 523 | (defn find-or-create! 524 | "Idempotently turn an EDN description into one or more Metabase entities. 525 | Combine this function with the result from [[dashboard]] or [[card]] to do the 526 | heavy lifting. 527 | 528 | This may create multiple entities, creating a Dashboard will also create (or 529 | reuse) Card and DashboardCard entities, and wire them up appropriately. 530 | 531 | Entities are cached in a content-addressed in-memory cache, so calling this 532 | twice with the same EDN will immediately return the previously created entity. 533 | 534 | Only the the in-memory cache is checked, so calling this on a freshly created 535 | connection will always end up creating new entities. It's up to you to call 536 | call [[populate-cache]] after creating your connection, or on a regular basis, 537 | so you can re-use what is already in Metabase. 538 | 539 | If there is a cache miss a new entity will be created, even though there may 540 | be an entity in Metabase with the same hash that we are simply not aware of. 541 | As such this function only attempts to reuse entities, it does not guarantee 542 | it." 543 | [conn {::keys [card dashboard-cards] :as entity}] 544 | (let [hash (edn->hash entity)] 545 | (with-cache conn [:by-hash hash] 546 | ;; Dashboard-card with a card, create/find the card first 547 | (let [entity (if card 548 | (let [card-id (or (:id card) 549 | (:id (find-or-create! conn card)))] 550 | (-> entity 551 | (assoc :cardId card-id) 552 | (update :parameter_mappings (partial map #(assoc % :card_id card-id))))) 553 | entity) 554 | ;; create the entity itself 555 | response (create-one! conn (add-hash entity hash))] 556 | ;; :enable_embedding is not respected on the initial POST, it has to be 557 | ;; set after the dashboard is created. 558 | (when (:enable_embedding entity) 559 | (mb-put conn 560 | (resource-path (assoc entity :id (:id response))) 561 | {:form-params (strip-embedkit-keys entity)})) 562 | ;; Dashboard with dashboard-cards 563 | (when dashboard-cards 564 | (run! #(find-or-create! conn (assoc % ::dashboard-id (:id response))) 565 | dashboard-cards)) 566 | response)))) 567 | 568 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 569 | ;; Data functions 570 | 571 | (defn native-card 572 | "Data definition of a native query. Returns a Card resource that can be passed to `create!`. 573 | 574 | - `:name` Name for this query 575 | - `:database` The metabase DB id 576 | - `:sql` The native query, as string or as honeysql query map. Can contain `{{variable}}` placeholders. 577 | - `:display` How to visualize the result, e.g. `\"table\"` 578 | - `:variables` Variables used in the native query, as a map from varname to options map 579 | " 580 | [{:keys [name database sql display variables] 581 | :or {display "table"} 582 | :as opts}] 583 | (let [variables (into {} 584 | (map (fn [[varname opts]] 585 | [varname (merge 586 | {:id (as-str varname) 587 | :name (as-str varname) 588 | :display-name (as-str varname) 589 | :type "text"} 590 | opts)])) 591 | variables)] 592 | {::type :card 593 | :name name 594 | :database_id database 595 | :query_type "native" 596 | :dataset_query {:database database 597 | :type "native" 598 | :native 599 | (cond-> {:query (if (map? sql) (format-sql sql) sql)} 600 | (seq variables) 601 | (assoc :template-tags variables))} 602 | :display (as-str display) 603 | :visualization_settings {} 604 | ::variables variables})) 605 | 606 | (defn viz-settings 607 | "Set a card's visualization settings, takes nested maps." 608 | [card settings] 609 | (update card :visualization_settings 610 | (fn [viz] 611 | (reduce (fn [viz [kk vs]] 612 | (reduce (fn [viz [k v]] 613 | (assoc viz 614 | (keyword (str (as-str kk) "." (as-str k))) 615 | v)) 616 | viz 617 | vs)) 618 | viz 619 | settings)))) 620 | 621 | (defn bar-chart 622 | "Display as a bar chart, provide at a minimum the `x-axis`/`y-axis` dimensions. 623 | Takes/returns a Card entity." 624 | [card {:keys [x-axis y-axis 625 | x-label y-label 626 | log? 627 | stacked?]}] 628 | (assert x-axis) 629 | (assert y-axis) 630 | (-> card 631 | (assoc :display "bar") 632 | (viz-settings (cond-> {:graph {:dimensions (cond-> x-axis 633 | (not (coll? x-axis)) 634 | vec) 635 | :metrics (cond-> y-axis 636 | (not (coll? y-axis)) 637 | vec)}} 638 | x-label 639 | (assoc-in [:graph :x_axis :title_text] x-label) 640 | y-label 641 | (assoc-in [:graph :x_axis :title_text] y-label) 642 | log? 643 | (assoc-in [:graph :y_axis :scale] "log") 644 | stacked? 645 | (assoc-in [:stackable :stack_type] "stacked"))))) 646 | 647 | (defn dashboard-card 648 | "Data definition of a DashboardCard entity" 649 | [{:keys [card card-id x y width height 650 | dashboard dashboard-id] 651 | :or {width 10 height 10 652 | x 0 y 0}}] 653 | (let [card-id (or card-id (:id card))] 654 | (cond-> {::type :dashboard-card 655 | ::dashboard-id (or dashboard-id (:id dashboard)) 656 | ::card card 657 | :cardId card-id 658 | :size_x width 659 | :size_y height 660 | :parameter_mappings (for [[var-name {:keys [id]}] (::variables card)] 661 | {:parameter_id id 662 | :target ["variable" ["template-tag" id]] 663 | ;; Likely still nil here, unless the card has 664 | ;; already been created. Will get set during 665 | ;; creation 666 | :card_id card-id})} 667 | y (assoc :row y) 668 | x (assoc :col x)))) 669 | 670 | (defn dashboard 671 | "Data definition of a Dashboard entity." 672 | [{:keys [name cards]}] 673 | (let [variables (apply merge (map (comp ::variables :card) cards))] 674 | {::type :dashboard 675 | :name name 676 | :enable_embedding true 677 | :embedding_params (reduce (fn [m {:keys [name editable?]}] 678 | (assoc m name (if editable? "enabled" "locked"))) 679 | {} 680 | (vals variables)) 681 | ::dashboard-cards (map dashboard-card cards) 682 | :parameters (for [{:keys [id name]} (vals variables)] 683 | {:id id 684 | :slug name 685 | :name name 686 | :type "id"})})) 687 | -------------------------------------------------------------------------------- /src/lambdaisland/embedkit/repl.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.embedkit.repl 2 | "REPL helpers" 3 | (:require [clojure.java.browse :as browse] 4 | [clojure.string :as str] 5 | [lambdaisland.embedkit :as e])) 6 | 7 | (defn delete-all-cards! 8 | "Clean up after experimenting. 'Card' here means metabase query, they use the 9 | terms interchangably" 10 | [conn] 11 | (doseq [{:keys [id]} (:body (e/mb-get conn [:card]))] 12 | (println "Deleting card" id) 13 | (e/mb-delete conn [:card id]))) 14 | 15 | (defn delete-all-dashboards! 16 | [conn] 17 | (doseq [{:keys [id]} (:body (e/mb-get conn [:dashboard]))] 18 | (println "Deleting dashboard" id) 19 | (e/mb-delete conn [:dashboard id]))) 20 | 21 | (defn browse! [res] 22 | (browse/browse-url (str "http://localhost:3000/" 23 | (cond 24 | (= :card (::e/type res)) 25 | "question" 26 | (= :dashboard (::e/type res)) 27 | "dashboard") 28 | "/" 29 | (:id res)))) 30 | (defn print-request-mw 31 | "Very basic Hato middleware to see which requests are being made" 32 | [c] 33 | (fn 34 | ([req] 35 | (println (:request-method req) (:url req)) 36 | (c req)) 37 | ([req resp raise] 38 | (println (:request-method req) (:url req)) 39 | (c req resp raise)))) 40 | -------------------------------------------------------------------------------- /src/lambdaisland/embedkit/setup.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.embedkit.setup 2 | "The automation helpers to automate the init setup of metabase" 3 | (:require [lambdaisland.embedkit :as embedkit] 4 | [clojure.data.json :as json] 5 | [hato.client :as http])) 6 | 7 | (defn metabase-endpoint 8 | "Create the base url of metabase" 9 | [https? host port] 10 | (str "http" (when https? "s") "://" host (when port (str ":" port)))) 11 | 12 | (defn get-metabase-setup-token! 13 | "Get the metabase setup token by using /api/session/properties GET API" 14 | [base-url] 15 | (let [session-url (str base-url "/api/session/properties") 16 | {:keys [status body]} (http/request {:method :get 17 | :url session-url 18 | :content-type :json})] 19 | (when (= 200 status) 20 | (:setup-token (json/read-str body 21 | :key-fn keyword))))) 22 | 23 | (defn create-admin-user! 24 | "Create the first/admin user of the metabase, and get the session-key" 25 | [{:keys [base-url setup-token email password 26 | first-name last-name site-name]}] 27 | (let [setup-url (str base-url "/api/setup") 28 | data {:token setup-token 29 | :user {:email email :password password 30 | :first_name first-name :last_name last-name} 31 | :prefs {:site_name site-name}} 32 | {:keys [status body]} (http/request {:method :post 33 | :url setup-url 34 | :content-type :json 35 | :form-params data})] 36 | (when (= 200 status) 37 | (:id (json/read-str body 38 | :key-fn keyword))))) 39 | 40 | (defn enable-embedding! 41 | "change the metabase setting using /api/setting PUT API to enable embedding" 42 | [base-url session-key] 43 | (let [setting-url (str base-url "/api/setting/enable-embedding") 44 | data {:key "enable-embedding" 45 | :value true} 46 | {:keys [status body]} (http/request {:headers {"x-metabase-session" session-key} 47 | :method :put 48 | :url setting-url 49 | :content-type :json 50 | :form-params data})] 51 | (if (= 200 status) 52 | (json/read-str body 53 | :key-fn keyword) 54 | (prn {:api-endpoint "/api/setting/enable-embedding" 55 | :status status 56 | :body body})))) 57 | 58 | (defn get-metabase-setting! 59 | "get metabase setting using /api/setting GET API" 60 | ([e-conn] 61 | (let [resp (embedkit/mb-get e-conn "/api/setting/")] 62 | (:body resp))) 63 | ([e-conn key] 64 | (let [resp (embedkit/mb-get e-conn "/api/setting/") 65 | kv-pairs (filterv #(= (:key %) key) (:body resp))] 66 | (first kv-pairs)))) 67 | 68 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 69 | ;; init API 70 | 71 | (defn init-metabase! 72 | "Doing the following things: 73 | 74 | 1. create the first user 75 | 2. enable embedding" 76 | [{:keys [user password 77 | ;; optional 78 | first-name last-name site-name 79 | https? host port] 80 | :or {first-name "lambdaisland.com" 81 | last-name "gaiwan.co" 82 | site-name "Metabase BI" 83 | https? false 84 | host "localhost" 85 | port 3000}}] 86 | (let [base-url (metabase-endpoint https? host port) 87 | setup-token (get-metabase-setup-token! base-url) 88 | session-key (create-admin-user! {:base-url base-url 89 | :setup-token setup-token 90 | :email user 91 | :password password 92 | :first-name first-name 93 | :last-name last-name 94 | :site-name site-name})] 95 | (comment (prn {:base-url base-url 96 | :setup-token setup-token 97 | :session-key session-key})) 98 | (enable-embedding! base-url session-key))) 99 | 100 | (defn get-embedding-secret-key 101 | "retrive the embedding-secret-key" 102 | [e-conn] 103 | {:pre [(satisfies? embedkit/IConnection e-conn)]} 104 | (get-metabase-setting! e-conn "embedding-secret-key")) 105 | 106 | (defn create-db! 107 | "Create the database in metabase if it does not exist" 108 | [e-conn db-conn-name engine details] 109 | {:pre [(satisfies? embedkit/IConnection e-conn) (string? db-conn-name) (string? engine) (map? details)]} 110 | (if (nil? (embedkit/find-database e-conn db-conn-name)) 111 | (embedkit/mb-post 112 | e-conn 113 | "/api/database" 114 | {:form-params {:name db-conn-name 115 | :engine engine 116 | :details details}}) 117 | (prn "duplicated db-conn-name " db-conn-name "already exists."))) 118 | -------------------------------------------------------------------------------- /src/lambdaisland/embedkit/watch.clj: -------------------------------------------------------------------------------- 1 | (ns lambdaisland.embedkit.watch 2 | "Watch Metabase entities, development aid. 3 | 4 | This is meant to help you figure out how operations in the UI correspond with 5 | data in the API. You [[watch!]] a given path, it will be polled, and every 6 | time the response differs from the previous response, you will see any changed 7 | or new values in the response." 8 | (:require [lambdaisland.embedkit :as e] 9 | [clojure.pprint :as pprint])) 10 | 11 | (defn- base-type [obj] 12 | (cond 13 | (map? obj) :map 14 | (sequential? obj) :seq 15 | (number? obj) :number 16 | (string? obj) :string 17 | :else (class obj))) 18 | 19 | (defn only-changes [before after] 20 | (if (= (base-type before) (base-type after)) 21 | (cond 22 | (map? after) 23 | (into {} 24 | (comp (remove (fn [[k v]] 25 | (= v (get before k)))) 26 | (map (fn [[k v]] 27 | [k (only-changes (get before k) v)]))) 28 | after) 29 | (sequential? after) 30 | (into [] 31 | (map (fn [[v1 v2]] (only-changes v1 v2))) 32 | (loop [res [] 33 | [b & bs] before 34 | [a & as] after] 35 | (if (or (seq bs) (seq as)) 36 | (recur (conj res [b a]) bs as) 37 | (conj res [b a])))) 38 | :else after) 39 | after)) 40 | 41 | (defonce watches (atom {})) 42 | 43 | (defn watch! 44 | "Watch a given resource, this keep re-fetching it from the API, and printing 45 | any keys that have changed. This allows us to make changes in the UI and see 46 | how they correspond with the data." 47 | [conn path] 48 | (let [obj (:body (e/mb-get conn path))] 49 | (swap! watches assoc path obj) 50 | (future 51 | (loop [] 52 | (let [before (get @watches path)] 53 | (when before 54 | (let [after (:body (e/mb-get conn path))] 55 | (when (not= before after) 56 | (println "----" path "----") 57 | (pprint/pprint (only-changes before after)) 58 | (swap! watches path after) 59 | (Thread/sleep 2500)) 60 | (recur)))))))) 61 | 62 | (defn unwatch! [id] 63 | (swap! watches dissoc id)) 64 | 65 | (comment 66 | (watch my-conn [:card 12]) 67 | (watch my-conn [:dashboard 100])) 68 | -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 2 | {:plugins [:notifier :print-invocations :profiling]} 3 | --------------------------------------------------------------------------------