├── .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 |
--------------------------------------------------------------------------------