57 | DBCore reads your database and generates high-quality,
58 | statically typed Go code (for the API) and TypeScript (for
59 | the UI) based on templates.
60 |
61 |
62 | Not happy with the built-in templates? Write your
63 | own to generate any kind of application you want.
64 |
65 | Screenshot of the examples/todo application.
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
Generating a Go API
76 |
77 |
78 | In your project root folder create a dbcore.yml:
79 |
80 |
project: $project-name
81 | database:
82 | # Or 'mysql'
83 | dialect: postgres
84 |
85 | # Omit if localhost
86 | host: $host-or-ip
87 |
88 | # Omit if 5432 for postgres or 3306 for mysql
89 | port: $port
90 |
91 | database: $database-name
92 | username: $username
93 | password: $password
94 |
95 | api:
96 | template: go
97 | outDir: $outDir
98 |
99 | # e.g. "v1/", for URLs
100 | routerPrefix: $router-prefix
101 |
102 | audit:
103 | # Disabled by default
104 | enabled: true
105 | createdAt: $createdAtColumn
106 | updatedAt: $updatedAtColumn
107 | deletedAt: $deletedAtColumn
108 |
109 | auth:
110 | # Disabled by default
111 | enabled: true
112 | table: $users
113 | # Column for username field
114 | username: $username
115 | # Column for password field, bcrypt hash is stored
116 | password: $password
117 |
118 | extra:
119 | repo: $your-repo
120 |
121 | # Configuration that is read only at runtime and can be modified
122 | # with only a restart not a regeneration.
123 | runtime:
124 | # Or use '$username:$password@tcp($host:$port)/$database?sql_mode=ANSI', sql_mode=ANSI is required
125 | dsn: postgres://$username:$password@$host:$port/$database?sslmode=disable
126 |
127 | session:
128 | duration: 2hr
129 | secret: $my-secret-signing-key
130 |
131 | Clone the repo and run make && make install
132 | within the repo root. You will need Docker, only.
133 |
134 |
135 | Then go to your project directory and run
136 | dbcore . to generate the project. Finally
137 | run go run cmd/main.go to start the server.
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
API Specification
146 |
147 |
Authentication
148 |
149 | When authentication is enabled, make a
150 | JSON POST request with {"username":
151 | "$your-username", "password": "$your-password"}
152 | to /$version/session/start to generate a
153 | token. It is valid for 2 hours by default.
154 |
155 |
156 | Passwords are stored as BCrypt hashes.
157 |
158 |
159 | You can store this token in the au cookie or
160 | you can submit it as bearer token by setting
161 | the Authorization header to BEARER
162 | $your-token.
163 |
170 | Allow lists per endpoint and method are specified in ANSI
171 | SQL. If you only include a WHERE clause
172 | (omitting the WHERE token) it will default to
173 | applying this search on the endpoint's table.
174 |
175 |
176 | Allowances are specified in the
177 | api.runtime.auth.allow section and are a dictionary
178 | mapping table labels to a dictionary mapping methods to
179 | filters.
180 |
181 |
182 | Request variables can be interpolated into the filter for
183 | session-based authorization.
184 |
185 |
Built-in request variables
186 |
187 |
188 |
189 |
190 |
Parameter
191 |
Definition
192 |
Example
193 |
194 |
195 |
196 |
197 |
$req_username
198 |
Username of the current session
199 |
admin
200 |
201 |
202 |
$req_object_id
203 |
204 | Id of the current object being acted on, depends on
205 | the type of the primary key. Null if not relevant.
206 |
207 |
1
208 |
209 |
210 |
211 |
212 |
Example
213 |
api:
214 | runtime:
215 | auth:
216 | allow:
217 | notes:
218 | # Must be public or tied to the current user's organization.
219 | get: |
220 | is_public IS TRUE OR
221 | created_by IN (
222 | SELECT id
223 | FROM users
224 | WHERE organization IN (
225 | SELECT organization
226 | FROM users
227 | WHERE username = $req_username
228 | )
229 | )
230 | # Must be created by the user or tied to the current user's organization and an admin.
231 | put: &ownedOrOrgAdmin |
232 | created_by IN (
233 | SELECT id
234 | FROM users
235 | WHERE
236 | organization IN (
237 | SELECT organization
238 | FROM users
239 | WHERE username = $req_username
240 | ) AND
241 | (is_admin IS TRUE OR username = $req_username)
242 | )
243 | # Same as edit (put)
244 | delete: *ownedOrOrgAdmin
245 | # Must be in the same org
246 | post: |
247 | SELECT id
248 | FROM users
249 | WHERE
250 | organization IN (
251 | SELECT organization
252 | FROM users
253 | WHERE username = $req_username
254 | )
255 |
256 | Filters are only applied if the key exists and is not the
257 | empty string. For an in-depth example. See the
258 | [organization-oriented example note-taking app in the
259 | repo](https://github.com/eatonphil/dbcore/tree/master/examples/notes).
260 |
261 |
262 |
Get many rows from a table
263 |
264 | Make a GET request to /$version/$table.
265 |
349 | Make a DELETE request to /$version/$table/$id.
350 |
351 |
352 | This endpoint is only available if the table has a primary key.
353 |
354 |
Example
355 |
$ curl -X DELETE localhost:9090/v1/users/1
356 |
357 |
358 |
359 |
360 |
361 |
362 |
Generating a TypeScript/React UI
363 |
364 |
365 | Using the same configuration as for the API, after
366 | running dbcore . you can run yarn
367 | start in browser/ to start the
368 | application at http://localhost:9091.
369 |
370 |
371 | Use browser.defaultRoute to override the
372 | default home page.
373 |
374 |
375 |
376 |
377 |
378 |
--------------------------------------------------------------------------------
/docs/main.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | font-family: sans-serif;
7 | font-size: 1.1rem;
8 | line-height: 1.5rem;
9 | }
10 |
11 | h1, h2 {
12 | line-height: 2.2rem;
13 | }
14 |
15 | section {
16 | padding: 50px 0;
17 | }
18 |
19 | section:nth-child(odd) {
20 | background: #f0f0f0;
21 | border-top: 1px solid #ddd;
22 | border-bottom: 1px solid #ddd;
23 | }
24 |
25 | section:nth-child(odd) code {
26 | background: #ddd;
27 | }
28 |
29 | table {
30 | width: 100%;
31 | border: 1px solid #ccc;
32 | }
33 |
34 | th, td {
35 | text-align: left;
36 | padding: 15px;
37 | }
38 |
39 | th, tr:not(:last-of-type) td {
40 | border-bottom: 1px solid #ccc;
41 | }
42 |
43 | .container {
44 | width: 100%;
45 | max-width: 600px;
46 | margin: 0 auto;
47 | }
48 |
49 | .container:before, .container:after{
50 | content: ' ';
51 | display: table;
52 | }
53 |
54 | .d-flex {
55 | display: flex;
56 | align-items: center;
57 | }
58 |
59 | pre {
60 | background: white;
61 | border: 1px solid #ccc;
62 | padding: 50px;
63 | overflow: auto;
64 | width: 800px;
65 | margin-left: -100px;
66 | }
67 |
68 | code {
69 | background: #eee;
70 | color: #bb2c4f;
71 | padding: 0 5px;
72 | }
73 |
74 | pre code {
75 | background: initial !important;
76 | color: initial;
77 | padding: initial;
78 | }
79 |
80 | .text-right {
81 | text-align: right;
82 | }
83 |
84 | section.hero {
85 | padding: 100px 0;
86 | background: #841443;
87 | color: white;
88 | }
89 |
90 | .hero header {
91 | color: white;
92 | }
93 |
94 | .hero h1 {
95 | margin: 0;
96 | margin-bottom: 50px;
97 | font-size: 100px;
98 | }
99 |
100 | .hero strong {
101 | font-weight: initial;
102 | border-bottom: 2px solid white;
103 | }
104 |
105 | .hero ul {
106 | line-height: 2rem;
107 | }
108 |
109 | .hero p {
110 | color: #eee;
111 | }
112 |
113 | ul.checks {
114 | list-style: none;
115 | }
116 |
117 | ul.checks li:before {
118 | content: '\2713';
119 | color: #00ff00;
120 | margin-left: -20px;
121 | margin-right: 10px;
122 | }
123 |
124 | .hero li {
125 | padding: 3px 0;
126 | }
127 |
128 | .hero .star {
129 | text-align: center;
130 | padding: 15px 0;
131 | }
132 |
133 | @media (max-width: 800px) {
134 | .container {
135 | width: 100%;
136 | padding: 0 15px;
137 | }
138 |
139 | pre {
140 | width: 100%;
141 | margin-left: 0;
142 | }
143 | }
144 |
145 | @media (max-width: 411px) {
146 | .hero h1 {
147 | font-size: 75px;
148 | margin: 15px 0;
149 | }
150 |
151 | .hero img {
152 | display: none;
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/docs/reset.css:
--------------------------------------------------------------------------------
1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in iOS.
9 | */
10 |
11 | html {
12 | line-height: 1.15; /* 1 */
13 | -webkit-text-size-adjust: 100%; /* 2 */
14 | }
15 |
16 | /* Sections
17 | ========================================================================== */
18 |
19 | /**
20 | * Remove the margin in all browsers.
21 | */
22 |
23 | body {
24 | margin: 0;
25 | }
26 |
27 | /**
28 | * Render the `main` element consistently in IE.
29 | */
30 |
31 | main {
32 | display: block;
33 | }
34 |
35 | /**
36 | * Correct the font size and margin on `h1` elements within `section` and
37 | * `article` contexts in Chrome, Firefox, and Safari.
38 | */
39 |
40 | h1 {
41 | font-size: 2em;
42 | margin: 0.67em 0;
43 | }
44 |
45 | /* Grouping content
46 | ========================================================================== */
47 |
48 | /**
49 | * 1. Add the correct box sizing in Firefox.
50 | * 2. Show the overflow in Edge and IE.
51 | */
52 |
53 | hr {
54 | box-sizing: content-box; /* 1 */
55 | height: 0; /* 1 */
56 | overflow: visible; /* 2 */
57 | }
58 |
59 | /**
60 | * 1. Correct the inheritance and scaling of font size in all browsers.
61 | * 2. Correct the odd `em` font sizing in all browsers.
62 | */
63 |
64 | pre {
65 | font-family: monospace, monospace; /* 1 */
66 | font-size: 1em; /* 2 */
67 | }
68 |
69 | /* Text-level semantics
70 | ========================================================================== */
71 |
72 | /**
73 | * Remove the gray background on active links in IE 10.
74 | */
75 |
76 | a {
77 | background-color: transparent;
78 | }
79 |
80 | /**
81 | * 1. Remove the bottom border in Chrome 57-
82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
83 | */
84 |
85 | abbr[title] {
86 | border-bottom: none; /* 1 */
87 | text-decoration: underline; /* 2 */
88 | text-decoration: underline dotted; /* 2 */
89 | }
90 |
91 | /**
92 | * Add the correct font weight in Chrome, Edge, and Safari.
93 | */
94 |
95 | b,
96 | strong {
97 | font-weight: bolder;
98 | }
99 |
100 | /**
101 | * 1. Correct the inheritance and scaling of font size in all browsers.
102 | * 2. Correct the odd `em` font sizing in all browsers.
103 | */
104 |
105 | code,
106 | kbd,
107 | samp {
108 | font-family: monospace, monospace; /* 1 */
109 | font-size: 1em; /* 2 */
110 | }
111 |
112 | /**
113 | * Add the correct font size in all browsers.
114 | */
115 |
116 | small {
117 | font-size: 80%;
118 | }
119 |
120 | /**
121 | * Prevent `sub` and `sup` elements from affecting the line height in
122 | * all browsers.
123 | */
124 |
125 | sub,
126 | sup {
127 | font-size: 75%;
128 | line-height: 0;
129 | position: relative;
130 | vertical-align: baseline;
131 | }
132 |
133 | sub {
134 | bottom: -0.25em;
135 | }
136 |
137 | sup {
138 | top: -0.5em;
139 | }
140 |
141 | /* Embedded content
142 | ========================================================================== */
143 |
144 | /**
145 | * Remove the border on images inside links in IE 10.
146 | */
147 |
148 | img {
149 | border-style: none;
150 | }
151 |
152 | /* Forms
153 | ========================================================================== */
154 |
155 | /**
156 | * 1. Change the font styles in all browsers.
157 | * 2. Remove the margin in Firefox and Safari.
158 | */
159 |
160 | button,
161 | input,
162 | optgroup,
163 | select,
164 | textarea {
165 | font-family: inherit; /* 1 */
166 | font-size: 100%; /* 1 */
167 | line-height: 1.15; /* 1 */
168 | margin: 0; /* 2 */
169 | }
170 |
171 | /**
172 | * Show the overflow in IE.
173 | * 1. Show the overflow in Edge.
174 | */
175 |
176 | button,
177 | input { /* 1 */
178 | overflow: visible;
179 | }
180 |
181 | /**
182 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
183 | * 1. Remove the inheritance of text transform in Firefox.
184 | */
185 |
186 | button,
187 | select { /* 1 */
188 | text-transform: none;
189 | }
190 |
191 | /**
192 | * Correct the inability to style clickable types in iOS and Safari.
193 | */
194 |
195 | button,
196 | [type="button"],
197 | [type="reset"],
198 | [type="submit"] {
199 | -webkit-appearance: button;
200 | }
201 |
202 | /**
203 | * Remove the inner border and padding in Firefox.
204 | */
205 |
206 | button::-moz-focus-inner,
207 | [type="button"]::-moz-focus-inner,
208 | [type="reset"]::-moz-focus-inner,
209 | [type="submit"]::-moz-focus-inner {
210 | border-style: none;
211 | padding: 0;
212 | }
213 |
214 | /**
215 | * Restore the focus styles unset by the previous rule.
216 | */
217 |
218 | button:-moz-focusring,
219 | [type="button"]:-moz-focusring,
220 | [type="reset"]:-moz-focusring,
221 | [type="submit"]:-moz-focusring {
222 | outline: 1px dotted ButtonText;
223 | }
224 |
225 | /**
226 | * Correct the padding in Firefox.
227 | */
228 |
229 | fieldset {
230 | padding: 0.35em 0.75em 0.625em;
231 | }
232 |
233 | /**
234 | * 1. Correct the text wrapping in Edge and IE.
235 | * 2. Correct the color inheritance from `fieldset` elements in IE.
236 | * 3. Remove the padding so developers are not caught out when they zero out
237 | * `fieldset` elements in all browsers.
238 | */
239 |
240 | legend {
241 | box-sizing: border-box; /* 1 */
242 | color: inherit; /* 2 */
243 | display: table; /* 1 */
244 | max-width: 100%; /* 1 */
245 | padding: 0; /* 3 */
246 | white-space: normal; /* 1 */
247 | }
248 |
249 | /**
250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
251 | */
252 |
253 | progress {
254 | vertical-align: baseline;
255 | }
256 |
257 | /**
258 | * Remove the default vertical scrollbar in IE 10+.
259 | */
260 |
261 | textarea {
262 | overflow: auto;
263 | }
264 |
265 | /**
266 | * 1. Add the correct box sizing in IE 10.
267 | * 2. Remove the padding in IE 10.
268 | */
269 |
270 | [type="checkbox"],
271 | [type="radio"] {
272 | box-sizing: border-box; /* 1 */
273 | padding: 0; /* 2 */
274 | }
275 |
276 | /**
277 | * Correct the cursor style of increment and decrement buttons in Chrome.
278 | */
279 |
280 | [type="number"]::-webkit-inner-spin-button,
281 | [type="number"]::-webkit-outer-spin-button {
282 | height: auto;
283 | }
284 |
285 | /**
286 | * 1. Correct the odd appearance in Chrome and Safari.
287 | * 2. Correct the outline style in Safari.
288 | */
289 |
290 | [type="search"] {
291 | -webkit-appearance: textfield; /* 1 */
292 | outline-offset: -2px; /* 2 */
293 | }
294 |
295 | /**
296 | * Remove the inner padding in Chrome and Safari on macOS.
297 | */
298 |
299 | [type="search"]::-webkit-search-decoration {
300 | -webkit-appearance: none;
301 | }
302 |
303 | /**
304 | * 1. Correct the inability to style clickable types in iOS and Safari.
305 | * 2. Change font properties to `inherit` in Safari.
306 | */
307 |
308 | ::-webkit-file-upload-button {
309 | -webkit-appearance: button; /* 1 */
310 | font: inherit; /* 2 */
311 | }
312 |
313 | /* Interactive
314 | ========================================================================== */
315 |
316 | /*
317 | * Add the correct display in Edge, IE 10+, and Firefox.
318 | */
319 |
320 | details {
321 | display: block;
322 | }
323 |
324 | /*
325 | * Add the correct display in all browsers.
326 | */
327 |
328 | summary {
329 | display: list-item;
330 | }
331 |
332 | /* Misc
333 | ========================================================================== */
334 |
335 | /**
336 | * Add the correct display in IE 10+.
337 | */
338 |
339 | template {
340 | display: none;
341 | }
342 |
343 | /**
344 | * Add the correct display in IE 10.
345 | */
346 |
347 | [hidden] {
348 | display: none;
349 | }
350 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eatonphil/dbcore/3a611aad39788b46cbd22aedbc85fb1b21b14d06/docs/screenshot.png
--------------------------------------------------------------------------------
/examples/notes/.gitignore:
--------------------------------------------------------------------------------
1 | browser
2 | go.mod
3 | go.sum
4 | api
5 | *.db
--------------------------------------------------------------------------------
/examples/notes/Makefile:
--------------------------------------------------------------------------------
1 | build:
2 | go build api/cmd/main.go
3 |
--------------------------------------------------------------------------------
/examples/notes/README.md:
--------------------------------------------------------------------------------
1 | ## Example
2 |
3 | 
4 |
5 | This is a built-in notes application with non-trivial
6 | authorization. Users belong to an org. Notes belong to a user. Notes
7 | that are marked public don't need a session. Otherwise they can only
8 | be viewed by other users within the same org. Only org admins or the
9 | notes creator can modify a note.
10 |
11 | ```bash
12 | $ git clone git@github.com:eatonphil/dbcore
13 | $ cd dbcore
14 | $ make example-notes
15 | $ cd ./examples/notes/api
16 | $ ./main
17 | INFO[0000] Starting server at :9090 pkg=server struct=Server
18 | ... in a new window ...
19 | $ curl -X POST -d '{"username": "alex", "password": "alex", "name": "Alex"}' localhost:9090/users/new
20 | {"id":1,"username":"alex","password":"alex","name":"Alex"}
21 | $ curl 'localhost:9090/users?limit=25&offset=0&sortColumn=id&sortOrder=desc' | jq
22 | {
23 | "total": 1,
24 | "data": [
25 | {
26 | "id": 1,
27 | "username": "alex",
28 | "password": "alex",
29 | "name": "Alex"
30 | },
31 | ]
32 | }
33 | ```
34 |
35 | And to build the UI:
36 |
37 | ```
38 | $ cd examples/notes/browser
39 | $ yarn start
40 | ```
41 |
42 | Log in with any of the following credentials:
43 |
44 | * admin:admin (Org 1)
45 | * notes-admin:admin (Org 2)
46 | * editor:editor (Org 2)
47 |
--------------------------------------------------------------------------------
/examples/notes/dbcore.yml:
--------------------------------------------------------------------------------
1 | project: notes
2 |
3 | database:
4 | dialect: sqlite
5 | database: notes.db
6 |
7 | api:
8 | routerPrefix: v1/
9 | auth:
10 | enabled: true
11 | audit:
12 | enabled: true
13 | createdAt: created_at
14 | updatedAt: updated_at
15 | deletedAt: deleted_at
16 | extra:
17 | repo: github.com/eatonphil/dbcore-notes
18 | runtime:
19 | dsn: file:../notes.db
20 | address: localhost:9090
21 | allowed-origins:
22 | - http://localhost:9091
23 | session:
24 | secret: NOTES
25 | auth:
26 | allow:
27 | notes:
28 | # Must be public or tied to the current user's organization.
29 | get: |
30 | is_public IS TRUE OR
31 | created_by IN (
32 | SELECT id
33 | FROM users
34 | WHERE organization IN (
35 | SELECT organization
36 | FROM users
37 | WHERE username = $req_username
38 | )
39 | )
40 | # Must be created by the user or tied to the current user's organization and an admin.
41 | put: &ownedOrOrgAdmin |
42 | created_by IN (
43 | SELECT id
44 | FROM users
45 | WHERE
46 | organization IN (
47 | SELECT organization
48 | FROM users
49 | WHERE username = $req_username
50 | ) AND
51 | (is_admin IS TRUE OR username = $req_username)
52 | )
53 | # Same as edit (put)
54 | delete: *ownedOrOrgAdmin
55 | # Must be in the same org
56 | post: |
57 | SELECT id
58 | FROM users
59 | WHERE
60 | organization IN (
61 | SELECT organization
62 | FROM users
63 | WHERE username = $req_username
64 | )
65 | users:
66 | # Must be in the same org
67 | get: &inOrg |
68 | organization IN (
69 | SELECT o.id
70 | FROM organizations o
71 | JOIN users u ON u.organization = o.id
72 | WHERE u.username = $req_username
73 | )
74 | put: *inOrg
75 | delete: &inOrgAndIsAdmin |
76 | created_by = $req_username OR
77 | organization IN (
78 | SELECT o.id
79 | FROM organizations o
80 | JOIN users u ON organization = o.id
81 | WHERE u.username = $req_username AND u.is_admin IS TRUE
82 | )
83 | post: *inOrgAndIsAdmin
84 | organizations:
85 | get: |
86 | id IN (
87 | SELECT o.id
88 | FROM organizations o
89 | JOIN users u ON u.organization = o.id
90 | WHERE u.username = $req_username
91 | )
92 | put: &inOrgAndIsAdmin |
93 | id IN (
94 | SELECT o.id
95 | FROM organizations o
96 | JOIN users u ON u.organization = o.id
97 | WHERE u.username = $req_username AND u.is_admin IS TRUE
98 | )
99 | delete: *inOrgAndIsAdmin
100 |
101 | browser: {}
102 |
--------------------------------------------------------------------------------
/examples/notes/sql/init.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO organizations
2 | VALUES (1, 'Admin', DATETIME('now'), DATETIME('now'), null);
3 |
4 | -- password: admin
5 | INSERT INTO users (is_admin, organization, username, password, name, created_at, updated_at)
6 | VALUES (TRUE, 1, 'admin', '$2y$12$rH9QTzZJmPGIPIfofMfRsOh8vD5u612VYOduOvq951vIp7ddQn4Ai', 'Admin', DATETIME('now'), DATETIME('now'));
7 |
8 | INSERT INTO organizations
9 | VALUES (2, 'Notes Today', DATETIME('now'), DATETIME('now'), null);
10 |
11 | -- password: admin
12 | INSERT INTO users (is_admin, organization, username, password, name, created_at, updated_at)
13 | VALUES (TRUE, 2, 'notes-admin', '$2y$12$rH9QTzZJmPGIPIfofMfRsOh8vD5u612VYOduOvq951vIp7ddQn4Ai', 'Notes Admin', DATETIME('now'), DATETIME('now'));
14 |
15 | -- password: editor
16 | INSERT INTO users (is_admin, organization, username, password, name, created_at, updated_at)
17 | VALUES (FALSE, 2, 'editor', '$2y$12$F5RlC/VhW1LNcCZi5ZZ1P.JnBvBShzSiMt3rKqZjhvJvylT6bJu2i', 'Editor', DATETIME('now'), DATETIME('now'));
18 |
--------------------------------------------------------------------------------
/examples/notes/sql/mysql/schema.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE users (
2 | id BIGINT AUTO_INCREMENT PRIMARY KEY,
3 | username TEXT NOT NULL,
4 | password TEXT NOT NULL,
5 | name TEXT NOT NULL,
6 | created_at DATETIME NOT NULL,
7 | updated_at DATETIME NOT NULL,
8 | deleted_at DATETIME,
9 | );
10 |
11 | CREATE TABLE notes (
12 | id BIGINT AUTO_INCREMENT PRIMARY KEY,
13 | note TEXT NOT NULL,
14 | created_by BIGINT NOT NULL,
15 | created_at DATETIME NOT NULL,
16 | updated_at DATETIME NOT NULL,
17 | deleted_at DATETIME,
18 | FOREIGN KEY (created_by) REFERENCES users (id)
19 | );
20 |
--------------------------------------------------------------------------------
/examples/notes/sql/psql/init.sql:
--------------------------------------------------------------------------------
1 | CREATE DATABASE notes;
2 | CREATE USER notes WITH PASSWORD 'notes';
3 | GRANT ALL ON DATABASE notes TO notes;
4 |
--------------------------------------------------------------------------------
/examples/notes/sql/psql/schema.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE users (
2 | id BIGSERIAL PRIMARY KEY,
3 | username TEXT NOT NULL,
4 | password TEXT NOT NULL,
5 | name TEXT NOT NULL,
6 | created_at TIMESTAMPTZ NOT NULL,
7 | updated_at TIMESTAMPTZ NOT NULL,
8 | deleted_at TIMESTAMPTZ
9 | );
10 |
11 | CREATE TABLE notes (
12 | id BIGSERIAL PRIMARY KEY,
13 | note TEXT NOT NULL,
14 | created_by BIGINT NOT NULL,
15 | created_at TIMESTAMPTZ NOT NULL,
16 | updated_at TIMESTAMPTZ NOT NULL,
17 | deleted_at TIMESTAMPTZ,
18 | FOREIGN KEY (created_by) REFERENCES users (id)
19 | );
20 |
--------------------------------------------------------------------------------
/examples/notes/sql/sqlite/schema.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE organizations (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | name TEXT NOT NULL,
4 | created_at TEXT NOT NULL,
5 | updated_at TEXT NOT NULL,
6 | deleted_at TEXT
7 | );
8 |
9 | CREATE TABLE users (
10 | id INTEGER PRIMARY KEY AUTOINCREMENT,
11 | username TEXT NOT NULL,
12 | password TEXT NOT NULL,
13 | name TEXT NOT NULL,
14 | created_at TEXT NOT NULL,
15 | updated_at TEXT NOT NULL,
16 | deleted_at TEXT,
17 | organization INTEGER NOT NULL,
18 | is_admin INTEGER NOT NULL,
19 | FOREIGN KEY (organization) REFERENCES organizations (id)
20 | );
21 |
22 | CREATE TABLE notes (
23 | id INTEGER PRIMARY KEY AUTOINCREMENT,
24 | note TEXT NOT NULL,
25 | created_by INTEGER NOT NULL,
26 | created_at TEXT NOT NULL,
27 | updated_at TEXT NOT NULL,
28 | deleted_at TEXT,
29 | is_public INTEGER NOT NULL,
30 | FOREIGN KEY (created_by) REFERENCES users (id)
31 | );
32 |
--------------------------------------------------------------------------------
/src/Config.fs:
--------------------------------------------------------------------------------
1 | module Config
2 |
3 | open System.Collections.Generic
4 | open System.IO
5 |
6 | open YamlDotNet.Serialization
7 | open YamlDotNet.Serialization.NamingConventions
8 |
9 |
10 | type DatabaseTableConfig() =
11 | let mutable label = ""
12 |
13 | member val Name = "" with get, set
14 |
15 | member this.Label
16 | with get() : string =
17 | if label <> "" then label else this.Name
18 | and set(value: string) = label <- value
19 |
20 |
21 | type DatabaseConfig() =
22 | let mutable port = ""
23 | let mutable schema = ""
24 |
25 | member val Dialect = "postgres" with get, set
26 | member val Host = "localhost" with get, set
27 | member val Database = "" with get, set
28 | member val Username = "" with get, set
29 | member val Password = "" with get, set
30 |
31 | member val Tables: array = [||] with get, set
32 |
33 | member this.Schema
34 | with get() : string =
35 | if schema <> "" then schema else
36 | match this.Dialect with
37 | | "postgres" -> "public"
38 | | "mysql" -> this.Database
39 | | "sqlite" -> ""
40 | | _ -> failwith ("database.schema must be set for unknown dialect: " + this.Dialect)
41 |
42 | member this.Port
43 | with get() : string =
44 | if port <> "" then port else
45 | match this.Dialect with
46 | | "postgres" -> "5432"
47 | | "mysql" -> "3306"
48 | | "sqlite" -> ""
49 | | _ -> failwith ("database.port must be set for unknown dialect: " + this.Dialect)
50 | and set(value: string) = port <- value
51 |
52 |
53 | type IConfig =
54 | abstract member OutDir : string with get, set
55 | abstract member Template : string with get, set
56 |
57 |
58 | type ApiAuthAllowConfig() =
59 | member val Get = "" with get, set
60 | member val Put = "" with get, set
61 | member val Post = "" with get, set
62 | member val Delete = "" with get, set
63 |
64 | type ApiAuthConfig() =
65 | member val Enabled = false with get, set
66 | member val Table = "users" with get, set
67 | member val Username = "username" with get, set
68 | member val Password = "password" with get, set
69 | member val Allow = Dictionary() with get, set
70 |
71 |
72 | type ApiAuditConfig() =
73 | member val Enabled = false with get, set
74 | member val CreatedAt = "" with get, set
75 | member val UpdatedAt = "" with get, set
76 | member val DeletedAt = "" with get, set
77 |
78 |
79 | type ApiConfig() =
80 | interface IConfig with
81 | member val OutDir = "api" with get, set
82 | member val Template = "go" with get, set
83 |
84 | member val Auth = ApiAuthConfig() with get, set
85 | member val RouterPrefix = "" with get, set
86 | member val Extra = Dictionary() with get, set
87 | member val Runtime = Dictionary() with get, set
88 |
89 | member val Audit = ApiAuditConfig() with get, set
90 |
91 | member this.Validate() =
92 | // TODO: should probably turn validation into a schema per template
93 | if (this :> IConfig).Template = "go" && not (this.Extra.ContainsKey "repo")
94 | then failwith "Repo is required: `api.extra.repo: $repo`"
95 |
96 |
97 | type BrowserConfig() =
98 | interface IConfig with
99 | member val OutDir = "browser" with get, set
100 | member val Template = "react" with get, set
101 |
102 | member val DefaultRoute = "" with get, set
103 |
104 |
105 | type CustomConfig() =
106 | interface IConfig with
107 | member val OutDir = "" with get, set
108 | member val Template = "" with get, set
109 |
110 | member val Extra = Dictionary() with get, set
111 |
112 | type Config() =
113 | member val Project = "" with get, set
114 | member val CultureName = System.Globalization.CultureInfo.CurrentCulture.Name with get, set
115 |
116 | member val Database = DatabaseConfig() with get, set
117 | member val Api = ApiConfig() with get, set
118 | member val Browser = BrowserConfig() with get, set
119 | member val Custom : array = [| |] with get, set
120 |
121 | member this.Validate() =
122 | if this.Project = "" then failwith "Project name is required: `project: $name`"
123 |
124 | this.Api.Validate()
125 |
126 | let GetConfig(f: string) : Config =
127 | use file = new FileStream(f, FileMode.Open, FileAccess.Read)
128 | use stream = new StreamReader(file)
129 | let deserializer =
130 | (DeserializerBuilder())
131 | .WithNamingConvention(CamelCaseNamingConvention.Instance)
132 | .Build()
133 | let config = deserializer.Deserialize(stream)
134 |
135 | config
136 |
--------------------------------------------------------------------------------
/src/Database.fs:
--------------------------------------------------------------------------------
1 | module Database
2 |
3 |
4 | type Column =
5 | {
6 | Name: string
7 | Type: string
8 | Nullable: bool
9 | AutoIncrement: bool
10 | }
11 |
12 |
13 | type Constraint =
14 | {
15 | Column: string
16 | Type: string
17 | ForeignTable: string
18 | ForeignColumn: string
19 | }
20 |
21 |
22 | type Table =
23 | {
24 | Name: string
25 | Columns: Column[]
26 | ForeignKeys: Constraint[]
27 | PrimaryKey: Option
28 | }
29 |
--------------------------------------------------------------------------------
/src/Program.fs:
--------------------------------------------------------------------------------
1 | open System.IO
2 |
3 | []
4 | let main (args: string []): int =
5 | let projectDir = if args.Length > 0
6 | then args.[0]
7 | else failwith "Expected project directory"
8 |
9 | let cfg = Config.GetConfig(Path.Combine(projectDir, "dbcore.yml"))
10 | cfg.Validate()
11 |
12 | if cfg.Database.Dialect = "sqlite" then
13 | cfg.Database.Database <- Path.Combine(projectDir, cfg.Database.Database)
14 | let db = Reader.Reader(cfg.Database)
15 | let tables: Template.Table[] =
16 | let notAuditOrAutoIncrement(c: Database.Column) : bool =
17 | if c.AutoIncrement then false
18 | else if not cfg.Api.Audit.Enabled then true
19 | else if (c.Name = cfg.Api.Audit.CreatedAt ||
20 | c.Name = cfg.Api.Audit.UpdatedAt ||
21 | c.Name = cfg.Api.Audit.DeletedAt) then false
22 | else true
23 |
24 | let notAutoIncrement(c: Database.Column) : bool = not c.AutoIncrement
25 |
26 | [|
27 | for table in db.GetTables() do
28 | let mutable label = table.Name
29 | for t in cfg.Database.Tables do
30 | if t.Name = table.Name then label <- t.Label
31 |
32 | let columnsNoAudit = table.Columns |> Array.filter notAuditOrAutoIncrement
33 | let columnsNoAutoIncrement = table.Columns |> Array.filter notAutoIncrement
34 |
35 | let t: Template.Table = {
36 | Label = label
37 | Name = table.Name
38 | Columns = table.Columns
39 | ForeignKeys = table.ForeignKeys
40 | PrimaryKey = table.PrimaryKey
41 |
42 | ColumnsNoAutoIncrement = columnsNoAutoIncrement
43 | ColumnsNoAudit = columnsNoAudit
44 | }
45 | t
46 | |]
47 |
48 | let ctx: Template.Context = {
49 | Project = cfg.Project
50 | Database = {| Dialect = cfg.Database.Dialect |}
51 | Api = cfg.Api
52 | Browser = cfg.Browser
53 | Tables = tables
54 | Template = ""
55 | OutDir = ""
56 | CultureName = cfg.CultureName
57 | }
58 | Template.GenerateApi(projectDir, cfg.Api, ctx)
59 | Template.GenerateBrowser(projectDir, cfg.Browser, ctx)
60 |
61 | 0
62 |
--------------------------------------------------------------------------------
/src/Reader/InformationSchema.fs:
--------------------------------------------------------------------------------
1 | namespace Reader
2 |
3 | open System.Data
4 |
5 | open Npgsql
6 | open MySql.Data.MySqlClient
7 |
8 | open Database
9 |
10 |
11 | // This is for SQL implementations that follow the ANSI standard of
12 | // using information_schema tables to store database metadata.
13 | []
14 | type InformationSchema(cfg0: Config.DatabaseConfig) =
15 | let cfg = cfg0
16 | let connFactory =
17 | match cfg.Dialect with
18 | | "mysql" -> fun (dsn) -> new MySqlConnection(dsn) :> IDbConnection
19 | | "postgres" -> fun (dsn) -> new NpgsqlConnection(dsn) :> IDbConnection
20 | | d -> failwith ("Unsupported SQL dialect: " + d)
21 |
22 | let getConn() : IDbConnection =
23 | let dsn =
24 | sprintf "Host=%s;Port=%s;Database=%s;Username=%s;Password=%s;"
25 | cfg.Host
26 | cfg.Port
27 | cfg.Database
28 | cfg.Username
29 | cfg.Password
30 | let conn = connFactory(dsn)
31 | conn.Open()
32 | conn
33 |
34 | let addStringParameter(cmd: IDbCommand, name: string, value: string) : unit =
35 | let p = cmd.CreateParameter()
36 | p.ParameterName <- name
37 | p.Value <- value
38 | cmd.Parameters.Add(p) |> ignore
39 |
40 | let getConstraints(conn: IDbConnection, table: string, typ: string) : Constraint[] =
41 | let query =
42 | match cfg.Dialect with
43 | | "postgres" ->
44 | "SELECT
45 | kcu.column_name,
46 | ccu.table_name,
47 | ccu.column_name
48 | FROM
49 | information_schema.table_constraints AS tc
50 | JOIN information_schema.key_column_usage AS kcu
51 | ON tc.constraint_name = kcu.constraint_name
52 | AND tc.table_schema = kcu.table_schema
53 | JOIN information_schema.constraint_column_usage AS ccu
54 | ON ccu.constraint_name = tc.constraint_name
55 | AND ccu.table_schema = tc.table_schema
56 | WHERE tc.constraint_type=@type AND tc.table_name=@name"
57 | | "mysql" ->
58 | "SELECT
59 | column_name,
60 | COALESCE(referenced_table_name, ''),
61 | COALESCE(referenced_column_name, '')
62 | FROM
63 | information_schema.table_constraints tc
64 | JOIN information_schema.key_column_usage kcu
65 | ON kcu.table_schema = tc.table_schema
66 | AND kcu.table_name = tc.table_name
67 | AND kcu.constraint_name = tc.constraint_name
68 | WHERE tc.constraint_type=@type AND tc.table_name=@name"
69 | | d -> failwith "Unknown dialect: " + d
70 | use cmd = conn.CreateCommand()
71 | cmd.CommandText <- query
72 |
73 | addStringParameter(cmd, "name", table)
74 | addStringParameter(cmd, "type", typ)
75 | cmd.Prepare()
76 | use dr = cmd.ExecuteReader()
77 | [|
78 | while dr.Read() do
79 | yield {
80 | Column = dr.GetString(0)
81 | Type = ""
82 | ForeignTable = dr.GetString(1)
83 | ForeignColumn = dr.GetString(2)
84 | }
85 | |]
86 |
87 | let getTable(conn: IDbConnection, name: string) : Table =
88 | let columns =
89 | let autoIncrementCheck =
90 | match cfg.Dialect with
91 | | "postgres" -> "COALESCE(column_default, '') LIKE '%seq%' -- Not the greatest way to detect auto incrementing, but ok for now"
92 | | "mysql" -> "extra LIKE '%auto_increment%'"
93 | | d -> failwith ("Unsupported SQL dialect: " + d)
94 | let query =
95 | sprintf "SELECT
96 | column_name,
97 | data_type,
98 | is_nullable <> 'NO',
99 | %s
100 | FROM
101 | information_schema.columns
102 | WHERE
103 | table_schema=@schema AND table_name=@name" autoIncrementCheck
104 | use cmd = conn.CreateCommand()
105 | cmd.CommandText <- query
106 | addStringParameter(cmd, "name", name)
107 | addStringParameter(cmd, "schema", cfg.Schema)
108 | cmd.Prepare()
109 | use dr = cmd.ExecuteReader()
110 | [|
111 | while dr.Read() do
112 | yield {
113 | Name = dr.GetString(0)
114 | Type = dr.GetString(1).ToLower()
115 | Nullable = dr.GetBoolean(2)
116 | AutoIncrement = dr.GetBoolean(3)
117 | }
118 | |]
119 |
120 | if columns.Length = 0
121 | then failwith ("Expected more than 0 columns in table " + name)
122 | else "" |> ignore
123 |
124 | let foreignKeys = getConstraints(conn, name, "FOREIGN KEY")
125 |
126 | let primaryKey =
127 | let keys = getConstraints(conn, name, "PRIMARY KEY")
128 | if keys.Length = 0 then None
129 | else
130 | let typ = [ for c in columns do
131 | if c.Name = keys.[0].Column then
132 | yield c.Type ].[0]
133 | Some ({ keys.[0] with Type = typ.ToLower() })
134 |
135 | {
136 | Name = name
137 | Columns = columns
138 | ForeignKeys = foreignKeys
139 | PrimaryKey = primaryKey
140 | }
141 |
142 | member this.GetTables() : Table[] =
143 | use conn = getConn()
144 |
145 | // Fetch names first so cmd can be closed
146 | let tableNames =
147 | let query =
148 | "SELECT
149 | table_name
150 | FROM
151 | information_schema.tables
152 | WHERE
153 | table_schema=@schema"
154 | use cmd = conn.CreateCommand()
155 | cmd.CommandText <- query
156 | addStringParameter(cmd, "schema", cfg.Schema)
157 |
158 | use dr = cmd.ExecuteReader()
159 | [| while dr.Read() do yield dr.GetString 0 |]
160 |
161 | [| for name in tableNames do yield getTable(conn, name) |]
162 |
--------------------------------------------------------------------------------
/src/Reader/Reader.fs:
--------------------------------------------------------------------------------
1 | namespace Reader
2 |
3 |
4 | type Reader(cfg0: Config.DatabaseConfig) =
5 | let cfg = cfg0
6 |
7 | member this.GetTables() : Database.Table[] =
8 | match cfg.Dialect with
9 | | "sqlite" -> SQLite(cfg).GetTables()
10 | | "mysql" | "postgres" -> InformationSchema(cfg).GetTables()
11 | | d -> failwith ("Unsupported SQL dialect: " + d)
12 |
13 |
--------------------------------------------------------------------------------
/src/Reader/SQLite.fs:
--------------------------------------------------------------------------------
1 | namespace Reader
2 |
3 | open System.Data
4 |
5 | open Microsoft.Data.Sqlite
6 |
7 | open Database
8 |
9 | type SQLite(cfg0: Config.DatabaseConfig) =
10 | let cfg = cfg0
11 |
12 | let getForeignKeys(conn: SqliteConnection, name: string) : Constraint[] =
13 | use cmd = conn.CreateCommand()
14 | cmd.CommandText <- """SELECT "from", "table", "to" FROM pragma_foreign_key_list($schema)"""
15 | let _ = cmd.Parameters.AddWithValue("$schema", cfg.Schema)
16 | cmd.Prepare()
17 | use dr = cmd.ExecuteReader()
18 | [|
19 | while dr.Read() do
20 | yield {
21 | Column = dr.GetString(0)
22 | Type = ""
23 | ForeignTable = dr.GetString(1)
24 | ForeignColumn = dr.GetString(2)
25 | }
26 | |]
27 |
28 | let getTable(conn: SqliteConnection, name: string) : Table =
29 | let mutable primaryKey : Option = None
30 | let columns =
31 | use cmd = conn.CreateCommand()
32 | cmd.CommandText <- """SELECT
33 | name,
34 | type,
35 | "notnull",
36 | pk = (SELECT 1 FROM sqlite_master WHERE tbl_name=$name AND sql LIKE '%%AUTOINCREMENT%%'),
37 | pk
38 | FROM
39 | pragma_table_info($name)"""
40 | let _ = cmd.Parameters.AddWithValue("$name", name)
41 | cmd.Prepare()
42 | use dr = cmd.ExecuteReader()
43 | [|
44 | while dr.Read() do
45 | let name = dr.GetString(0)
46 | let typ = dr.GetString(1).ToLower()
47 | let pk = dr.GetBoolean(4)
48 | if pk then
49 | primaryKey <- Some {
50 | Column = name
51 | Type = typ
52 | ForeignTable = ""
53 | ForeignColumn = ""
54 | }
55 |
56 | yield {
57 | Name = name
58 | Type = typ
59 | Nullable = not (pk || dr.GetBoolean 2)
60 | AutoIncrement = dr.GetBoolean(3)
61 | }
62 | |]
63 |
64 | if columns.Length = 0
65 | then failwith ("Expected more than 0 columns in table " + name)
66 | else "" |> ignore
67 |
68 | let foreignKeys = getForeignKeys(conn, name)
69 |
70 | {
71 | Name = name
72 | Columns = columns
73 | ForeignKeys = foreignKeys
74 | PrimaryKey = primaryKey
75 | }
76 |
77 | member this.GetTables() : Table[] =
78 | use conn = new SqliteConnection("Data Source=" + cfg.Database)
79 | conn.Open()
80 | let tableNames =
81 | use cmd = conn.CreateCommand()
82 | cmd.CommandText <- "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
83 |
84 | use dr = cmd.ExecuteReader()
85 | [| while dr.Read() do yield dr.GetString 0 |]
86 |
87 | [| for name in tableNames do yield getTable(conn, name) |]
88 |
--------------------------------------------------------------------------------
/src/Template.fs:
--------------------------------------------------------------------------------
1 | module Template
2 |
3 | open System.Diagnostics
4 | open System.IO
5 | open System.Text.RegularExpressions
6 |
7 |
8 | let rec private getFiles(dir: string) : seq =
9 | seq {
10 | yield! Directory.EnumerateFiles(dir, "*.*")
11 | for d in Directory.EnumerateDirectories(dir) do
12 | yield! getFiles(d)
13 | }
14 |
15 |
16 | type Table =
17 | {
18 | Name: string
19 | Label: string
20 | Columns: Database.Column[]
21 | ForeignKeys: Database.Constraint[]
22 | PrimaryKey: Option
23 |
24 | ColumnsNoAutoIncrement: Database.Column[]
25 | ColumnsNoAudit: Database.Column[]
26 | }
27 |
28 |
29 | []
30 | type Context =
31 | {
32 | Project: string
33 | Database: {| Dialect: string |}
34 | Tables: Table[]
35 | Api: Config.ApiConfig
36 | Browser: Config.BrowserConfig
37 | OutDir: string
38 | Template: string
39 | CultureName: string
40 | }
41 |
42 |
43 | let private writeProjectToDisk(sourceDir: string, outDir: string, ctx: Context) =
44 | let ti = System.Globalization.CultureInfo(ctx.CultureName, false).TextInfo
45 |
46 | // Register helpers
47 | let helpers =
48 | [
49 | ("dbcore_capitalize", System.Converter(fun a -> ti.ToTitleCase(a)));
50 | ]
51 | let tctx = Scriban.TemplateContext()
52 | let so = Scriban.Runtime.ScriptObject()
53 | for (name, fn) in helpers do
54 | Scriban.Runtime.ScriptObjectExtensions.Import(so, name, fn)
55 | tctx.PushGlobal(so)
56 |
57 | // Copy/render each template
58 | for f in getFiles(sourceDir) do
59 | let tpl = Scriban.Template.Parse(File.ReadAllText(f), f)
60 |
61 | // Drop the SourceDir/ prefix
62 | let f = f.Substring(sourceDir.Length + 1)
63 |
64 | // Order by substring length descending
65 | let pathTemplateExpansions =
66 | [
67 | ("tables_capitalize",
68 | fun (path: string, sub: string) ->
69 | [ for t in ctx.Tables do
70 | yield (path.Replace(sub, ti.ToTitleCase(t.Label)),
71 | {| ctx with Table = t |}) ]);
72 | ("tables",
73 | fun (path: string, sub: string) ->
74 | [ for t in ctx.Tables do
75 | yield (path.Replace(sub, t.Label),
76 | {| ctx with Table = t |}) ]);
77 | ("",
78 | fun (path: string, sub: string) ->
79 | [(path, {| ctx with Table = ctx.Tables.[0] |})])
80 | ]
81 |
82 | let mutable fsAndCtxs = []
83 | for (path, expand) in pathTemplateExpansions do
84 | let escapedPath = sprintf "DBCORE__%s__" path
85 | // Only expand once on the longest match.
86 | if fsAndCtxs.Length = 0 && (f.Contains(escapedPath) || path = "") then
87 | fsAndCtxs <- expand(f, escapedPath)
88 |
89 | for (f, ctx) in fsAndCtxs do
90 | let outFile = Path.Combine(outDir, f)
91 | printfn "[DEBUG] Generating: %s" outFile
92 |
93 | // Create directory if not exists
94 | FileInfo(outFile).Directory.Create()
95 |
96 | tctx.PushGlobal(Scriban.Runtime.ScriptObject.From(ctx))
97 | File.WriteAllText(outFile, tpl.Render(tctx))
98 |
99 |
100 | let private generate(templateDir: string, projectDir: string, cfg: Config.IConfig, ctx: Context) =
101 | // Required for distribution to get right base path (especially within single file executable)
102 | let baseDir = System.AppContext.BaseDirectory
103 | let sourceDir = Path.Combine(baseDir, templateDir, cfg.Template)
104 | let outDir = Path.Combine(projectDir, cfg.OutDir)
105 | writeProjectToDisk(sourceDir, outDir, { ctx with OutDir=cfg.OutDir; Template=cfg.Template })
106 |
107 | printfn "[DEBUG] Running post install script: %s"
108 | (Path.Combine(outDir, "scripts/post-generate.sh"))
109 | let processInfo = new ProcessStartInfo(
110 | FileName = "bash",
111 | Arguments = "scripts/post-generate.sh",
112 | WorkingDirectory = outDir)
113 | use p = Process.Start(processInfo)
114 | p.WaitForExit()
115 |
116 |
117 | let GenerateApi(projectDir: string, cfg: Config.IConfig, ctx: Context) =
118 | generate("templates/api", projectDir, cfg, ctx)
119 |
120 | let GenerateBrowser(projectDir: string, cfg: Config.IConfig, ctx: Context) =
121 | generate("templates/browser", projectDir, cfg, ctx)
122 |
--------------------------------------------------------------------------------
/templates/api/go/Makefile:
--------------------------------------------------------------------------------
1 | build:
2 | go build cmd/main.go
3 |
--------------------------------------------------------------------------------
/templates/api/go/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "{{ api.extra.repo }}/{{ out_dir }}/pkg/server"
4 |
5 | func main() {
6 | cfg, err := server.NewConfig("dbcore.yml")
7 | if err != nil {
8 | panic(err)
9 | }
10 |
11 | s, err := server.New(cfg)
12 | if err != nil {
13 | panic(err)
14 | }
15 |
16 | s.Start()
17 | }
18 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/dao/DBCORE__tables___dao.go:
--------------------------------------------------------------------------------
1 | package dao
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/guregu/null"
8 | "github.com/jmoiron/sqlx"
9 | )
10 |
11 | {{~
12 | func toGoType
13 | case $0.type
14 | when "int", "integer"
15 | if $0.nullable
16 | "null.Int"
17 | else
18 | "int32"
19 | end
20 | when "bigint"
21 | if $0.nullable
22 | "null.Int"
23 | else
24 | "int64"
25 | end
26 | when "text", "varchar", "char"
27 | if $0.nullable
28 | "null.String"
29 | else
30 | "string"
31 | end
32 | when "boolean"
33 | if $0.nullable
34 | "null.Bool"
35 | else
36 | "bool"
37 | end
38 | when "timestamp", "timestamp with time zone"
39 | if $0.nullable
40 | "null.Time"
41 | else
42 | "time.Time"
43 | end
44 | else
45 | "Unsupported type: " + $0.type
46 | end
47 | end
48 | ~}}
49 |
50 | type {{ table.label|dbcore_capitalize }} struct {
51 | {{~ for column in table.columns ~}}
52 | C_{{ column.name }} {{ toGoType column }} `db:"{{ column.name }}" json:"{{ column.name }}"`
53 | {{~ end ~}}
54 | }
55 |
56 | type {{ table.label|dbcore_capitalize }}PaginatedResponse struct {
57 | Total uint64 `json:"total"`
58 | Data []{{ table.label|dbcore_capitalize }} `json:"data"`
59 | }
60 |
61 | func (d DAO) {{ table.label|dbcore_capitalize }}GetMany(
62 | where *Filter,
63 | p Pagination,
64 | baseWhere string,
65 | baseCtx map[string]interface{},
66 | ) (*{{ table.label|dbcore_capitalize }}PaginatedResponse, error) {
67 | if where == nil {
68 | where = &Filter{}
69 | {{ if api.audit.enabled && api.audit.deleted_at }}
70 | where.filter = `WHERE "{{ api.audit.deleted_at }}" IS NULL`
71 | {{~ end ~}}
72 | } {{~ if api.audit.enabled && api.audit.deleted_at ~}} else {
73 | where.filter = where.filter + ` AND
74 | "{{ api.audit.deleted_at }}" IS NULL`
75 | }{{~ end ~}}
76 |
77 | {{~ if api.auth.enabled ~}}
78 | if baseWhere != "" {
79 | stmt, args := d.{{ table.label }}FilterToCompleteSQLStatement(baseWhere, baseCtx)
80 |
81 | // Combine base filter and where filter strings and args
82 | // TODO: handle restrictions on tables without a primary key
83 | where.filter = where.filter + ` AND "{{ table.primary_key.value.column }}" IN (` + stmt + ")"
84 | where.args = append(where.args, args...)
85 | }
86 | {{~ end ~}}
87 |
88 | query := fmt.Sprintf(`
89 | SELECT
90 | {{~ for column in table.columns ~}}
91 | "{{ column.name }}"{{ if !for.last || database.dialect != "sqlite" }},{{ end }}
92 | {{~ end ~}}
93 | {{~ if database.dialect != "sqlite" ~}}
94 | COUNT(1) OVER() AS __total
95 | {{~ end ~}}
96 | FROM
97 | "{{ table.name }}" t
98 | %s
99 | ORDER BY
100 | %s
101 | LIMIT %d
102 | OFFSET %d`, where.filter, p.Order, p.Limit, p.Offset)
103 | d.logger.Debug(query)
104 | rows, err := d.db.Queryx(query, where.args...)
105 | if err != nil {
106 | return nil, fmt.Errorf("Error in query: %s", err)
107 | }
108 | defer rows.Close()
109 |
110 | var response {{ table.label|dbcore_capitalize }}PaginatedResponse
111 | response.Data = []{{ table.label|dbcore_capitalize }}{}
112 | for rows.Next() {
113 | if err := rows.Err(); err != nil {
114 | return nil, err
115 | }
116 |
117 | var row struct {
118 | {{ table.label|dbcore_capitalize }}
119 | {{~ if database.dialect != "sqlite" ~}}
120 | Total uint64 `db:"__total"`
121 | {{~ end ~}}
122 | }
123 | err := rows.StructScan(&row)
124 | if err != nil {
125 | return nil, fmt.Errorf("Error scanning struct: %s", err)
126 | }
127 |
128 | {{~ if database.dialect != "sqlite" ~}}
129 | response.Total = row.Total
130 | {{~ end ~}}
131 | response.Data = append(response.Data, row.{{ table.label|dbcore_capitalize }})
132 | }
133 |
134 | {{~ if database.dialect == "sqlite" ~}}
135 | // COUNT() OVER() doesn't seem to work in the Go SQLite
136 | // package even though it works in the sqlite3 CLI.
137 | query = fmt.Sprintf(`
138 | SELECT
139 | COUNT(1)
140 | FROM
141 | "{{table.name}}"
142 | %s
143 | ORDER BY
144 | %s`, where.filter, p.Order)
145 | d.logger.Debug(query)
146 | row := d.db.QueryRowx(query, where.args...)
147 | err = row.Scan(&response.Total)
148 | if err != nil {
149 | return nil, fmt.Errorf("Error fetching total: %s", err, query)
150 | }
151 | {{~ end ~}}
152 |
153 | err = rows.Err()
154 | return &response, err
155 | }
156 |
157 | func (d DAO) {{ table.label|dbcore_capitalize }}Insert(body *{{ table.label|dbcore_capitalize }}) error {
158 | query := `
159 | INSERT INTO {{ table.name }} (
160 | {{~ for column in table.columns_no_audit ~}}
161 | "{{ column.name }}"{{ if !for.last }},{{ end }}
162 | {{~ end ~}}
163 | {{~ if api.audit.enabled ~}}
164 | , "{{ api.audit.created_at }}", "{{ api.audit.updated_at }}"
165 | {{~ end ~}})
166 | VALUES (
167 | {{~ for column in table.columns_no_audit ~}}
168 | {{ if database.dialect == "postgres" }}${{ for.index + 1 }}{{ else }}?{{ end }}{{ if !for.last }}, {{ end }}
169 | {{~ end ~}},
170 | {{~ if api.audit.enabled ~}}
171 | {{~ if database.dialect == "sqlite" ~}}
172 | DATETIME('now'),
173 | DATETIME('now')
174 | {{~ else ~}}
175 | NOW(),
176 | NOW()
177 | {{~ end ~}}
178 | {{~ end ~}})`
179 | d.logger.Debug(query)
180 |
181 | {{~ if database.dialect == "postgres" ~}}
182 | row := d.db.QueryRowx(query +`
183 | RETURNING {{ if table.primary_key.value }}{{ table.primary_key.value.column }}{{ else }}{{ table.columns[0].name }}{{ end }}
184 | `,
185 | {{~ for column in table.columns_no_audit ~}}
186 | body.C_{{ column.name }},
187 | {{~ end ~}}
188 | )
189 | return row.Scan(&body.C_{{ if table.primary_key.value }}{{ table.primary_key.value.column }}{{ else }}{{ table.columns[0].name }}{{ end }})
190 | {{~ else if database.dialect == "mysql" || database.dialect == "sqlite" ~}}
191 | stmt, err := d.db.Prepare(query)
192 | if err != nil {
193 | return err
194 | }
195 |
196 | {{ if database.dialect == "mysql" || database.dialect == "sqlite" }}var res sql.Result{{ end }}
197 | {{ if database.dialect == "mysql" || database.dialect == "sqlite" }}res{{ else }}_{{ end }}, err = stmt.Exec(
198 | {{~ for column in table.columns_no_audit ~}}
199 | body.C_{{ column.name }},
200 | {{~ end ~}})
201 | if err != nil {
202 | return err
203 | }
204 |
205 | {{~ if table.primary_key.value ~}}
206 | id, err := res.LastInsertId()
207 | if err != nil {
208 | return err
209 | }
210 |
211 | body.C_{{ table.primary_key.value.column }} = {{ toGoType table.primary_key.value }}(id)
212 | {{~ end ~}}
213 | return nil
214 | {{~ end ~}}
215 | }
216 |
217 | {{ if table.primary_key.value }}
218 | func (o {{ table.label|dbcore_capitalize }}) Id() {{ toGoType table.primary_key.value }} {
219 | return o.C_{{ table.primary_key.value.column }}
220 | }
221 |
222 | func (d DAO) {{ table.label|dbcore_capitalize }}Get(
223 | key {{ toGoType table.primary_key.value }},
224 | ) (*{{ table.label|dbcore_capitalize }}, error) {
225 | where, err := ParseFilter(fmt.Sprintf("{{ table.primary_key.value.column }} = %#v", key))
226 | if err != nil {
227 | panic(err)
228 | }
229 |
230 | pagination := Pagination{
231 | Limit: 1,
232 | Offset: 0,
233 | Order: fmt.Sprintf("{{ table.primary_key.value.column }} DESC"),
234 | }
235 |
236 | r, err := d.{{ table.label|dbcore_capitalize }}GetMany(where, pagination, "", nil)
237 | if err != nil {
238 | return nil, err
239 | }
240 |
241 | if r.Total != 1 {
242 | return nil, ErrNotFound
243 | }
244 |
245 | return &r.Data[0], nil
246 | }
247 |
248 | func (d DAO) {{ table.label|dbcore_capitalize }}Update(key {{ toGoType table.primary_key.value }}, body {{ table.label|dbcore_capitalize }}) error {
249 | query := `
250 | UPDATE
251 | "{{ table.name }}"
252 | SET
253 | {{~ for column in table.columns_no_audit ~}}
254 | "{{column.name}}" = {{ if database.dialect == "postgres" }}${{ index }}{{ else }}?{{ end }},
255 | {{~ end ~}}
256 | {{~ if database.dialect == "sqlite" ~}}
257 | "{{ api.audit.updated_at }}" = DATETIME('now')
258 | {{~ else ~}}
259 | "{{ api.audit.updated_at }}" = NOW()
260 | {{~ end ~}}
261 | WHERE
262 | {{~ if database.dialect == "postgres" ~}}
263 | "{{ table.primary_key.value.column }}" = ${{ table.columns_no_audit | array.size + 1 }}
264 | {{~ else ~}}
265 | "{{ table.primary_key.value.column }}" = ?
266 | {{~ end ~}}`
267 | d.logger.Debug(query)
268 | stmt, err := d.db.Prepare(query)
269 | if err != nil {
270 | return nil
271 | }
272 |
273 | _, err = stmt.Exec(
274 | {{~ for column in table.columns_no_audit ~}}
275 | body.C_{{ column.name }},
276 | {{~ end ~}}
277 | key)
278 | return err
279 | }
280 |
281 | func (d DAO) {{ table.label|dbcore_capitalize }}Delete(key {{ toGoType table.primary_key.value }}) error {
282 | query := `
283 | {{~ if api.audit.enabled && api.audit.deleted_at ~}}
284 | UPDATE
285 | "{{ table.name }}"
286 | SET
287 | {{~ if database.dialect == "sqlite" }}
288 | "{{ api.audit.deleted_at }}" = DATETIME('now')
289 | {{~ else ~}}
290 | "{{ api.audit.deleted_at }}" = NOW()
291 | {{~ end ~}}
292 | {{~ else ~}}
293 | DELETE
294 | FROM "{{ table.name }}"
295 | {{~ end ~}}
296 | WHERE
297 | "{{ table.primary_key.value.column }}" = {{ if database.dialect == "postgres" }}$1{{ else }}?{{ end }}`
298 |
299 | d.logger.Debug(query)
300 | stmt, err := d.db.Prepare(query)
301 | if err != nil {
302 | return err
303 | }
304 |
305 | _, err = stmt.Exec(key)
306 | return err
307 | }
308 |
309 | func (d DAO) {{ table.label }}FilterToCompleteSQLStatement(
310 | filter string,
311 | ctx map[string]interface{},
312 | ) (string, []interface{}) {
313 | query, args := applyVariablesFromContext(filter, ctx)
314 |
315 | // Allow override of select and from parts if specified
316 | _, err := sqlparser.Parse(query)
317 | if err != nil {
318 | // TODO: handle restrictions on tables without a primary key
319 |
320 | query = `SELECT "{{ table.primary_key.value.column }}" FROM "{{ table.name }}" WHERE ` + query
321 | }
322 |
323 | return query, args
324 | }
325 |
326 | func (d DAO) {{ table.label|dbcore_capitalize }}IsAllowed(filter string, ctx map[string]interface{}) bool {
327 | query, args := d.{{ table.label }}FilterToCompleteSQLStatement(filter, ctx)
328 |
329 | query = fmt.Sprintf(`SELECT COUNT(1) FROM (%s)`, query)
330 | d.logger.Debug(query)
331 | row := d.db.QueryRowx(query, args...)
332 |
333 | var count uint
334 | err := row.Scan(&count)
335 | if err != nil {
336 | d.logger.Warnf("Error fetching allow count: %s", err)
337 | return false
338 | }
339 |
340 | return count > 0
341 | }
342 |
343 | {{ end }}
344 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/dao/dao.go:
--------------------------------------------------------------------------------
1 | package dao
2 |
3 | import (
4 | "errors"
5 | "regexp"
6 |
7 | "github.com/jmoiron/sqlx"
8 | "github.com/sirupsen/logrus"
9 | "github.com/xwb1989/sqlparser"
10 | "github.com/xwb1989/sqlparser/dependency/querypb"
11 | "github.com/xwb1989/sqlparser/dependency/sqltypes"
12 | )
13 |
14 | var ErrNotFound = errors.New("Not found")
15 |
16 | type Pagination struct {
17 | Limit uint64
18 | Offset uint64
19 | Order string
20 | }
21 |
22 |
23 | type Filter struct {
24 | args []interface{}
25 | filter string
26 | }
27 |
28 | func ParseFilter(filter string) (*Filter, error) {
29 | if filter == "" {
30 | return nil, nil
31 | }
32 |
33 | // TODO: validate filter uses acceptable subset of WHERE
34 |
35 | // Add stub select to make filter into a statement
36 | stmt, err := sqlparser.Parse("SELECT 1 WHERE " + filter)
37 | if err != nil {
38 | return nil, err
39 | }
40 |
41 | where := stmt.(*sqlparser.Select).Where
42 |
43 | bindings := map[string]*querypb.BindVariable{}
44 | // dbcore is a random choice for a binding variable prefix so
45 | // we can be more certain when we find-replace on it
46 | sqlparser.Normalize(stmt, bindings, "dbcore")
47 |
48 | // Map :dbcore[0-9]* to $[0-9]*, will screw up if this literal
49 | // appeared in text in the query.
50 | re := regexp.MustCompile(":dbcore([0-9]*)")
51 |
52 | var invalidValue error
53 | var args []interface{}
54 | whereWithBindings := re.ReplaceAllStringFunc(sqlparser.String(where), func (match string) string {
55 | // This library has no sane way to produce a Go value
56 | // from a parsed bind variable.
57 | match = match[1:] // Drop the preceeding colon
58 | v, _ := sqltypes.BindVariableToValue(bindings[match])
59 | s := v.ToString()
60 | if v.IsSigned() {
61 | i, err := strconv.ParseInt(s, 10, 64)
62 | if err != nil {
63 | invalidValue = err
64 | return ""
65 | }
66 |
67 | args = append(args, i)
68 | } else if v.IsFloat() {
69 | fl, err := strconv.ParseFloat(s, 64)
70 | if err != nil {
71 | invalidValue = err
72 | return ""
73 | }
74 |
75 | args = append(args, fl)
76 | } else if v.IsText() || v.IsQuoted() {
77 | args = append(args, s)
78 | } else if v.IsNull() {
79 | args = append(args, nil)
80 | } else {
81 | invalidValue = fmt.Errorf(`Unsupported value: "%s"`, s)
82 | }
83 |
84 | {{ if database.dialect == "postgres" }}
85 | return fmt.Sprintf("$%d", len(args))
86 | {{ else if database.dialect == "mysql" || database.dialect == "sqlite" }}
87 | return "?"
88 | {{ end }}
89 | })
90 |
91 | return &Filter{
92 | // Take only the filter part from the statement
93 | filter: whereWithBindings,
94 | args: args,
95 | }, nil
96 | }
97 |
98 | // map $-prefixed variables from request context that can be turned
99 | // into parameterized queries
100 | func applyVariablesFromContext(filter string, ctx map[string]interface{}) (string, []interface{}) {
101 | re := regexp.MustCompile("\\$[a-zA-Z_]+")
102 | var args []interface{}
103 | applied := re.ReplaceAllStringFunc(filter, func (match string) string {
104 | mapping, ok := ctx[match[1:]] // skip $ prefix
105 | if mapping == nil || !ok {
106 | args = append(args, nil)
107 | } else {
108 | args = append(args, mapping)
109 | }
110 |
111 | {{ if database.dialect == "postgres" }}
112 | return fmt.Sprintf("$%d", len(args))
113 | {{ else if database.dialect == "mysql" || database.dialect == "sqlite" }}
114 | return "?"
115 | {{ end }}
116 | })
117 |
118 | return applied, args
119 | }
120 |
121 | type DAO struct {
122 | db *sqlx.DB
123 | logger logrus.FieldLogger
124 | }
125 |
126 | func New(db *sqlx.DB, logger logrus.FieldLogger) *DAO {
127 | return &DAO{
128 | db: db,
129 | logger: logger.WithFields(logrus.Fields{
130 | "struct": "DAO",
131 | "pkg": "dao",
132 | }),
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/server/DBCORE__tables___controller.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "golang.org/x/crypto/bcrypt"
8 |
9 | "github.com/julienschmidt/httprouter"
10 |
11 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao"
12 | )
13 |
14 | {{~ if table.primary_key.value ~}}
15 | {{~
16 | func toGoType
17 | case $0.type
18 | when "int", "integer"
19 | "int32"
20 | when "bigint"
21 | "int64"
22 | when "text", "varchar", "char"
23 | "string"
24 | when "boolean"
25 | "bool"
26 | when "timestamp", "timestamp with time zone"
27 | "time.Time"
28 | else
29 | "Unsupported type: " + $0.type
30 | end
31 | end
32 | ~}}
33 |
34 | func (s Server) {{ table.label }}RequestFilterContext(
35 | r *http.Request,
36 | objectId *{{ toGoType table.primary_key.value }},
37 | ) map[string]interface{} {
38 | ctx := map[string]interface{}{
39 | "req_username": s.getSessionUsername(r),
40 | }
41 |
42 | if objectId != nil {
43 | ctx["req_object_id"] = *objectId
44 | }
45 |
46 | return ctx
47 | }
48 |
49 | func (s Server) {{ table.label }}RequestIsAllowed(
50 | r *http.Request,
51 | filter string,
52 | objectId *{{ toGoType table.primary_key.value }},
53 | ) bool {
54 | ctx := s.{{ table.label }}RequestFilterContext(r, objectId)
55 | return s.dao.{{ table.label|dbcore_capitalize }}IsAllowed(filter, ctx)
56 | }
57 |
58 | func (s Server) {{ table.label }}GetManyController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
59 | extraFilter, pageInfo, err := getFilterAndPageInfo(r)
60 | if err != nil {
61 | sendErrorResponse(w, err)
62 | return
63 | }
64 |
65 | baseFilter := s.auth.allow["{{ table.label }}"]["get"]
66 | baseContext := s.{{ table.label }}RequestFilterContext(r, nil)
67 |
68 | result, err := s.dao.{{ table.label|dbcore_capitalize }}GetMany(extraFilter, *pageInfo, baseFilter, baseContext)
69 | if err != nil {
70 | sendErrorResponse(w, err)
71 | return
72 | }
73 |
74 | {{ if table.label == api.auth.table }}
75 | for i, _ := range result.Data {
76 | result.Data[i].C_{{ api.auth.password }} = ""
77 | }
78 | {{ end }}
79 |
80 | sendResponse(w, result)
81 | }
82 |
83 | func (s Server) {{ table.label }}CreateController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
84 | baseFilter := s.auth.allow["{{ table.label }}"]["post"]
85 | if baseFilter != "" {
86 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, nil) {
87 | sendAuthorizationErrorResponse(w)
88 | return
89 | }
90 | }
91 |
92 | var body dao.{{ table.label|dbcore_capitalize }}
93 | err := getBody(r, &body)
94 | if err != nil {
95 | s.logger.Debug("Expected valid JSON, got: %s", err)
96 | sendValidationErrorResponse(w, "Expected valid JSON")
97 | return
98 | }
99 |
100 | {{ if api.auth.enabled && table.label == api.auth.table }}
101 | hash, err := bcrypt.GenerateFromPassword(
102 | []byte(body.C_{{ api.auth.password }}), bcrypt.DefaultCost)
103 | body.C_{{ api.auth.password }} = string(hash)
104 | {{ end }}
105 |
106 | err = s.dao.{{ table.label|dbcore_capitalize }}Insert(&body)
107 | if err != nil {
108 | sendErrorResponse(w, err)
109 | return
110 | }
111 |
112 | {{ if table.label == api.auth.table }}
113 | body.C_{{ api.auth.password }} = ""
114 | {{ end }}
115 |
116 | sendResponse(w, body)
117 | }
118 |
119 | func parse{{ table.label|dbcore_capitalize }}Key(key string) {{ toGoType table.primary_key.value }} {
120 | {{~
121 | case table.primary_key.value.type
122 | when "text", "varchar", "char"
123 | "\t return key"
124 | when "int", "integer"
125 | "\t i, _ := strconv.ParseInt(key, 10, 32)\n\t return int32(i)"
126 | when "bigint"
127 | "\t i, _ := strconv.ParseInt(key, 10, 64)\n\t return i"
128 | when "timestamp", "timestamp with time zone"
129 | "\t t, _ := time.Parse(time.RFC3339, key)\n\t return t"
130 | when "boolean"
131 | "\t return key == \"true\""
132 | else
133 | "\t return \"\""
134 | end
135 | ~}}
136 | }
137 |
138 | func (s Server) {{ table.label }}GetController(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
139 | k := parse{{ table.label|dbcore_capitalize }}Key(ps.ByName("key"))
140 |
141 | baseFilter := s.auth.allow["{{ table.label }}"]["get"]
142 | if baseFilter != "" {
143 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, &k) {
144 | sendAuthorizationErrorResponse(w)
145 | return
146 | }
147 | }
148 |
149 | result, err := s.dao.{{ table.label|dbcore_capitalize }}Get(k)
150 | if err != nil {
151 | if err == dao.ErrNotFound {
152 | sendNotFoundErrorResponse(w)
153 | return
154 | }
155 |
156 | sendErrorResponse(w, err)
157 | return
158 | }
159 |
160 | {{ if table.label == api.auth.table }}
161 | result.C_{{ api.auth.password }} = ""
162 | {{ end }}
163 |
164 | sendResponse(w, result)
165 | }
166 |
167 | func (s Server) {{ table.label }}UpdateController(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
168 | k := parse{{ table.label|dbcore_capitalize }}Key(ps.ByName("key"))
169 |
170 | baseFilter := s.auth.allow["{{ table.label }}"]["put"]
171 | if baseFilter != "" {
172 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, &k) {
173 | sendAuthorizationErrorResponse(w)
174 | return
175 | }
176 | }
177 |
178 | var body dao.{{ table.label|dbcore_capitalize }}
179 | err := getBody(r, &body)
180 | if err != nil {
181 | s.logger.Debug("Expected valid JSON, got: %s", err)
182 | sendValidationErrorResponse(w, "Expected valid JSON")
183 | return
184 | }
185 |
186 | {{ if api.auth.enabled && table.label == api.auth.table }}
187 | result, err := s.dao.{{ table.label|dbcore_capitalize }}Get(k)
188 | if err != nil {
189 | sendErrorResponse(w, err)
190 | return
191 | }
192 |
193 | body.C_{{ api.auth.password }} = result.C_{{ api.auth.password }}
194 | {{ end }}
195 |
196 | body.C_{{ table.primary_key.value.column }} = k
197 | err = s.dao.{{ table.label|dbcore_capitalize }}Update(k, body)
198 | if err != nil {
199 | sendErrorResponse(w, err)
200 | return
201 | }
202 |
203 | {{ if table.label == api.auth.table }}
204 | body.C_{{ api.auth.password }} = ""
205 | {{ end }}
206 |
207 | sendResponse(w, body)
208 | }
209 |
210 | func (s Server) {{ table.label }}DeleteController(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
211 | k := parse{{ table.label|dbcore_capitalize }}Key(ps.ByName("key"))
212 |
213 | baseFilter := s.auth.allow["{{ table.label }}"]["delete"]
214 | if baseFilter != "" {
215 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, &k) {
216 | sendAuthorizationErrorResponse(w)
217 | return
218 | }
219 | }
220 |
221 | err := s.dao.{{ table.label|dbcore_capitalize }}Delete(k)
222 | if err != nil {
223 | sendErrorResponse(w, err)
224 | return
225 | }
226 |
227 | sendResponse(w, struct{}{})
228 | }
229 | {{~ end ~}}
230 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/server/config.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/spf13/viper"
8 | )
9 |
10 | type Config struct {
11 | *viper.Viper
12 | }
13 |
14 | func (c Config) GetString(arg string, defaults ...string) string {
15 | v := c.Viper.GetString(arg)
16 | if v != "" {
17 | return v
18 | }
19 |
20 | if len(defaults) == 1 {
21 | return defaults[0]
22 | }
23 |
24 | panic(fmt.Sprintf("Missing required configuration: %s", arg))
25 | }
26 |
27 | func (c Config) GetDuration(arg string, defaults ...time.Duration) time.Duration {
28 | v := c.Viper.GetDuration(arg)
29 | if v != 0 {
30 | return v
31 | }
32 |
33 | if len(defaults) == 1 {
34 | return defaults[0]
35 | }
36 |
37 | panic(fmt.Sprintf("Missing required configuration: %s", arg))
38 | }
39 |
40 | func NewConfig(file string) (*Config, error) {
41 | v := viper.New()
42 | v.SetConfigFile(file)
43 | err := v.ReadInConfig()
44 | return &Config{v}, err
45 | }
46 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/server/httputil.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "strconv"
8 | "strings"
9 |
10 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao"
11 | )
12 |
13 | func sendAuthorizationErrorResponse(w http.ResponseWriter) {
14 | w.WriteHeader(http.StatusForbidden)
15 | json.NewEncoder(w).Encode(struct{
16 | Error string `json:"error"`
17 | }{
18 | "Restricted interaction",
19 | })
20 | }
21 |
22 | func sendNotFoundErrorResponse(w http.ResponseWriter) {
23 | w.WriteHeader(http.StatusNotFound)
24 | json.NewEncoder(w).Encode(struct{
25 | Error string `json:"error"`
26 | }{
27 | "Not found",
28 | })}
29 |
30 |
31 | func sendValidationErrorResponse(w http.ResponseWriter, msg string) {
32 | w.WriteHeader(http.StatusBadRequest)
33 | json.NewEncoder(w).Encode(struct{
34 | Error string `json:"error"`
35 | }{
36 | msg,
37 | })
38 | }
39 |
40 | func sendErrorResponse(w http.ResponseWriter, err error) {
41 | w.WriteHeader(http.StatusInternalServerError)
42 | json.NewEncoder(w).Encode(struct{
43 | Error string `json:"error"`
44 | }{
45 | err.Error(),
46 | })
47 | }
48 |
49 | func sendResponse(w http.ResponseWriter, obj interface{}) {
50 | json.NewEncoder(w).Encode(obj)
51 | }
52 |
53 | func getBody(r *http.Request, obj interface{}) error {
54 | decoder := json.NewDecoder(r.Body)
55 | return decoder.Decode(obj)
56 | }
57 |
58 | func getFilterAndPageInfo(r *http.Request) (*dao.Filter, *dao.Pagination, error) {
59 | getSingleUintParameter := func(param string, def uint64) (uint64, error) {
60 | values, ok := r.URL.Query()[param]
61 | if !ok || len(values) == 0 {
62 | return def, nil
63 | }
64 |
65 | return strconv.ParseUint(values[0], 10, 64)
66 | }
67 |
68 | limit, err := getSingleUintParameter("limit", 25)
69 | if err != nil {
70 | return nil, nil, err
71 | }
72 |
73 | offset, err := getSingleUintParameter("offset", 0)
74 | if err != nil {
75 | return nil, nil, err
76 | }
77 |
78 | sortColumn := r.URL.Query().Get("sortColumn")
79 | if sortColumn == "" {
80 | return nil, nil, fmt.Errorf(`Expected "sortColumn" parameter`)
81 | }
82 |
83 | sortOrder := strings.ToUpper(r.URL.Query().Get("sortOrder"))
84 | if sortOrder == "" {
85 | sortOrder = "DESC"
86 | }
87 |
88 | if !(sortOrder == "ASC" || sortOrder == "DESC") {
89 | return nil, nil, fmt.Errorf(`Expected "sortOrder" parameter to be "asc" or "desc"`)
90 | }
91 |
92 | var filter *dao.Filter
93 | filterString := r.URL.Query().Get("filter")
94 | if filterString != "" {
95 | filter, err = dao.ParseFilter(filterString)
96 | if err != nil {
97 | return nil, nil, fmt.Errorf(`Expected valid "filter" parameter: %s`, err)
98 | }
99 | }
100 |
101 | return filter, &dao.Pagination{
102 | Limit: limit,
103 | Offset: offset,
104 | Order: sortColumn + " " + sortOrder,
105 | }, nil
106 | }
107 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/server/router.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 | "time"
7 |
8 | "github.com/dgrijalva/jwt-go"
9 | "github.com/sirupsen/logrus"
10 |
11 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao"
12 | )
13 |
14 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "runtime/debug"
6 | "fmt"
7 | "net/http"
8 | "os"
9 | "os/signal"
10 | "time"
11 |
12 | "github.com/jmoiron/sqlx"
13 | "github.com/julienschmidt/httprouter"
14 | {{ if database.dialect == "postgres" }}
15 | _ "github.com/lib/pq"
16 | {{ else if database.dialect == "mysql" }}
17 | _ "github.com/go-sql-driver/mysql"
18 | {{ else if database.dialect == "sqlite" }}
19 | _ "github.com/mattn/go-sqlite3"
20 | {{ end }}
21 | "github.com/sirupsen/logrus"
22 |
23 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao"
24 | )
25 |
26 | type serverAuth struct {
27 | secret string
28 | allow map[string]map[string]string
29 | }
30 |
31 | type Server struct {
32 | dao *dao.DAO
33 | router *httprouter.Router
34 | logger logrus.FieldLogger
35 | address string
36 | auth serverAuth
37 | sessionDuration time.Duration
38 | allowedOrigins []string
39 | }
40 |
41 | func (s Server) registerControllers() {
42 | {{~ for table in tables ~}}
43 | // Register {{table.label}} routes
44 | s.router.GET("/{{ api.router_prefix }}{{ table.label }}", s.{{table.label}}GetManyController)
45 | s.router.POST("/{{ api.router_prefix }}{{ table.label }}", s.{{table.label}}CreateController)
46 | {{~ if table.primary_key.value ~}}
47 | s.router.GET("/{{ api.router_prefix }}{{ table.label }}/:key", s.{{table.label}}GetController)
48 | s.router.PUT("/{{ api.router_prefix }}{{ table.label }}/:key", s.{{table.label}}UpdateController)
49 | s.router.DELETE("/{{ api.router_prefix }}{{ table.label }}/:key", s.{{table.label}}DeleteController)
50 | {{~ end ~}}
51 | {{ end }}
52 |
53 | {{ if api.auth.enabled }}
54 | // Register session route
55 | s.router.POST("/{{ api.router_prefix }}session/start", s.SessionStartController)
56 | s.router.POST("/{{ api.router_prefix }}session/stop", s.SessionStopController)
57 | {{ end }}
58 | }
59 |
60 | func (s Server) registerSigintHandler(srv *http.Server) {
61 | // Wait for SIGINT
62 | stop := make(chan os.Signal, 1)
63 | signal.Notify(stop, os.Interrupt)
64 | <-stop
65 |
66 | s.logger.Info("Signal received, shutting down")
67 |
68 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
69 | defer cancel()
70 | if err := srv.Shutdown(ctx); err != nil {
71 | s.logger.Warnf("Error during shutdown: %s", err)
72 | return
73 | }
74 | }
75 |
76 | func (s Server) handlePanic(w http.ResponseWriter, r *http.Request, err interface{}) {
77 | s.logger.Warnf("Unexpected panic: %s\n%s", err, debug.Stack())
78 | sendErrorResponse(w, fmt.Errorf("Internal server error"))
79 | }
80 |
81 | func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82 | s.logger.Infof("%s %s", r.Method, r.URL.RequestURI())
83 | w.Header().Set("Content-Type", "application/json")
84 |
85 | for _, allowed := range s.allowedOrigins {
86 | origin := strings.ToLower(r.Header.Get("origin"))
87 | if strings.ToLower(allowed) == origin {
88 | w.Header().Set("Access-Control-Allow-Origin", origin)
89 | w.Header().Set("Access-Control-Allow-Credentials", "true")
90 | w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
91 | w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, Origin")
92 | }
93 |
94 | if r.Method == http.MethodOptions {
95 | return
96 | }
97 | }
98 |
99 | s.router.ServeHTTP(w, r)
100 | }
101 |
102 |
103 | func (s Server) Start() {
104 | s.router.PanicHandler = s.handlePanic
105 | s.registerControllers()
106 |
107 | srv := &http.Server{
108 | Handler: s,
109 | Addr: s.address,
110 | WriteTimeout: 15 * time.Second,
111 | ReadTimeout: 15 * time.Second,
112 | }
113 |
114 | go func() {
115 | s.logger.Infof("Starting server at %s", s.address)
116 | if err := srv.ListenAndServe(); err != nil {
117 | s.logger.Warnf("Exiting server with error: %s", err)
118 | return
119 | }
120 |
121 | s.logger.Info("Exiting server")
122 | }()
123 |
124 | s.registerSigintHandler(srv)
125 | }
126 |
127 | func New(conf *Config) (*Server, error) {
128 | dialect := conf.GetString("database.dialect")
129 | dsn := conf.GetString("api.runtime.dsn")
130 |
131 | if dialect == "sqlite" {
132 | dialect = "sqlite3"
133 | }
134 |
135 | db, err := sqlx.Connect(dialect, dsn)
136 | if err != nil {
137 | return nil, err
138 | }
139 |
140 | secret := conf.GetString("api.runtime.session.secret")
141 | {{ if api.auth.enabled }}
142 | if secret == "" {
143 | return nil, fmt.Errorf(`Configuration value "secret" must be specified`)
144 | }
145 | {{ end }}
146 |
147 | router := httprouter.New()
148 | logger := logrus.New()
149 | // TODO: make this configurable
150 | logger.SetLevel(logrus.DebugLevel)
151 |
152 | return &Server{
153 | dao: dao.New(db, logger),
154 | router: router,
155 | logger: logger.WithFields(logrus.Fields{
156 | "struct": "Server",
157 | "pkg": "server",
158 | }),
159 | address: conf.GetString("api.runtime.address", ":9090"),
160 | sessionDuration: conf.GetDuration("api.runtime.session.duration", time.Hour * 2),
161 | allowedOrigins : conf.GetStringSlice("api.runtime.allowed-origins"),
162 | auth: serverAuth{
163 | secret: secret,
164 | allow: map[string]map[string]string{
165 | {{~ for table in tables ~}}
166 | "{{ table.label }}": map[string]string {
167 | "get": conf.GetString("api.runtime.auth.allow.{{ table.label }}.get", ""),
168 | "put": conf.GetString("api.runtime.auth.allow.{{ table.label }}.put", ""),
169 | "post": conf.GetString("api.runtime.auth.allow.{{ table.label }}.post", ""),
170 | "delete": conf.GetString("api.runtime.auth.allow.{{ table.label }}.delete", ""),
171 | },
172 | {{~ end ~}}
173 | },
174 | },
175 | }, nil
176 | }
177 |
--------------------------------------------------------------------------------
/templates/api/go/pkg/server/session_controller.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | {{ if api.auth.enabled }}
4 | import (
5 | "net/http"
6 | "strings"
7 | "time"
8 |
9 | "golang.org/x/crypto/bcrypt"
10 |
11 | "github.com/dgrijalva/jwt-go"
12 | "github.com/julienschmidt/httprouter"
13 |
14 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao"
15 | )
16 |
17 | func (s Server) getSessionUsername(r *http.Request) string {
18 | cookie, err := r.Cookie("au")
19 | if err != nil {
20 | // Fall back to header check
21 | cookie = &http.Cookie{}
22 | }
23 |
24 | token := cookie.Value
25 | if token == "" {
26 | authHeader := r.Header.Get("Authorization")
27 | if len(authHeader) > len("bearer ") &&
28 | strings.ToLower(authHeader[:len("bearer ")]) == "bearer " {
29 | token = authHeader[len("bearer "):]
30 | }
31 | }
32 |
33 | t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
34 | if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
35 | return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
36 | }
37 |
38 | return []byte(s.auth.secret), nil
39 | })
40 | if err != nil {
41 | s.logger.Debugf("Error parsing JWT: %s", err)
42 | return ""
43 | }
44 |
45 | claims, ok := t.Claims.(jwt.MapClaims)
46 | if !(ok && t.Valid) {
47 | return ""
48 | }
49 |
50 | if err := claims.Valid(); err != nil {
51 | s.logger.Debugf("Invalid claims: %s", err)
52 | return ""
53 | }
54 |
55 | usernameInterface, ok := claims["username"]
56 | if !ok {
57 | return ""
58 | }
59 |
60 | username, ok := usernameInterface.(string)
61 | if !ok {
62 | return ""
63 | }
64 |
65 | return username
66 | }
67 |
68 | func (s Server) SessionStartController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
69 | var userPass struct {
70 | Username string `json:"username"`
71 | Password string `json:"password"`
72 | }
73 |
74 | err := getBody(r, &userPass)
75 | if err != nil {
76 | sendValidationErrorResponse(w, fmt.Sprintf("Expected username and password in body, got: %s", err))
77 | return
78 | }
79 |
80 | if userPass.Username == "" || userPass.Password == "" {
81 | sendValidationErrorResponse(w, "Expected username and password in body")
82 | return
83 | }
84 |
85 | q := fmt.Sprintf("{{ api.auth.username }} = '%s'", userPass.Username)
86 | filter, err := dao.ParseFilter(q)
87 | if err != nil {
88 | s.logger.Debugf("Error while parsing {{ api.auth.username }}: %s", err)
89 | sendValidationErrorResponse(w, "Expected valid username")
90 | return
91 | }
92 |
93 | pageInfo := dao.Pagination{Offset: 0, Limit: 1, Order: `"{{ api.auth.username }}" DESC`}
94 | result, err := s.dao.{{ api.auth.table|dbcore_capitalize }}GetMany(filter, pageInfo, "", nil)
95 | if err != nil {
96 | sendErrorResponse(w, err)
97 | return
98 | }
99 |
100 | if result.Total == 0 {
101 | sendValidationErrorResponse(w, "Invalid username or password")
102 | return
103 | }
104 |
105 | user := result.Data[0]
106 | err = bcrypt.CompareHashAndPassword([]byte(user.C_{{ api.auth.password }}), []byte(userPass.Password))
107 | if err != nil {
108 | sendValidationErrorResponse(w, "Invalid username or password")
109 | return
110 | }
111 |
112 | unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
113 | "username": user.C_{{ api.auth.username }},
114 | "user_id": user.Id(),
115 | "exp": time.Now().Add(s.sessionDuration).Unix(),
116 | "nbf": time.Now().Unix(),
117 | "iat": time.Now().Unix(),
118 | })
119 | token, err := unsignedToken.SignedString([]byte(s.auth.secret))
120 | if err != nil {
121 | s.logger.Debugf("Error signing string: %s", err)
122 | sendErrorResponse(w, fmt.Errorf("Internal server error"))
123 | return
124 | }
125 |
126 | http.SetCookie(w, &http.Cookie{
127 | Name: "au",
128 | Value: token,
129 | Expires: time.Now().Add(s.sessionDuration),
130 | Path: "/",
131 | })
132 |
133 | sendResponse(w, struct{
134 | Token string `json:"token"`
135 | }{token})
136 | }
137 |
138 | func (s Server) SessionStopController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
139 | http.SetCookie(w, &http.Cookie{
140 | Name: "au",
141 | Value: "",
142 | Expires: time.Now().Add(-1 * s.sessionDuration),
143 | Path: "/",
144 | })
145 |
146 | sendResponse(w, struct{
147 | Token string `json:"token"`
148 | }{""})
149 | }
150 | {{ else }}
151 | // Auth not enabled.
152 | {{ end }}
153 |
--------------------------------------------------------------------------------
/templates/api/go/scripts/post-generate.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | set -e
4 | set -u
5 | set -x
6 |
7 | go get golang.org/x/tools/cmd/goimports
8 | if [ ! -f ../go.mod ]; then
9 | (cd .. && go mod init {{ api.extra.repo }})
10 | fi
11 | goimports -w ./
12 |
--------------------------------------------------------------------------------
/templates/browser/react/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .module-cache
3 | *.log*
4 | build
5 | dist
6 | yarn-.*
--------------------------------------------------------------------------------
/templates/browser/react/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "semi": true,
4 | "useTabs": false,
5 | "tabWidth": 2,
6 | "bracketSpacing": true,
7 | "singleQuote": true,
8 | "trailingComma": "none",
9 | "printWidth": 120
10 | }
11 |
--------------------------------------------------------------------------------
/templates/browser/react/README.md:
--------------------------------------------------------------------------------
1 | # {{ project|dbcore_capitalize }} Browser Application
2 |
3 | To run development server:
4 |
5 | ```
6 | $ yarn start
7 | ```
8 |
9 | To typecheck:
10 |
11 | ```
12 | $ yarn typecheck
13 | ```
14 |
15 | To build:
16 |
17 | ```
18 | $ yarn build
19 | ```
20 |
--------------------------------------------------------------------------------
/templates/browser/react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "{{ project }}",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "start": "./scripts/start.sh",
6 | "build": "./scripts/build.sh",
7 | "typecheck": "tsc"
8 | },
9 | "dependencies": {
10 | "react": "^16.13.1",
11 | "react-dom": "^16.13.1",
12 | "react-router": "^5.2.0",
13 | "react-router-dom": "^5.2.0",
14 | "typescript": "^3.9.5"
15 | },
16 | "devDependencies": {
17 | "@types/react": "^16.9.38",
18 | "@types/react-dom": "^16.9.8",
19 | "@types/react-router": "^5.1.7",
20 | "@types/react-router-dom": "^5.1.5",
21 | "esbuild": "^0.4.9",
22 | "es-dev-server": "^1.54.1"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/templates/browser/react/scripts/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eux
4 |
5 | # Easier than depending on a yaml parser? TODO: deal with TLS in server
6 | API_PREFIX="http://$(cat ../dbcore.yml | grep address | awk '{ print $2 }' | xargs)"
7 | yarn esbuild src/main.tsx --bundle --define:window.DBCORE_API_PREFIX=\"$API_PREFIX\" '--define:process.env.NODE_ENV="production"' --minify --outfile=build/bundle.js
8 |
--------------------------------------------------------------------------------
/templates/browser/react/scripts/post-generate.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | set -e
4 | set -u
5 | set -x
6 |
7 | chmod +x ./scripts/*.sh
8 |
9 | yarn
10 |
--------------------------------------------------------------------------------
/templates/browser/react/scripts/start.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eux
4 |
5 | rm -rf build
6 | mkdir build
7 | # Easier than depending on a yaml parser? TODO: deal with TLS in server
8 | API_PREFIX="http://$(cat ../dbcore.yml | grep address | awk '{ print $2 }' | xargs)"
9 | yarn esbuild src/main.tsx --bundle --define:window.DBCORE_API_PREFIX=\"$API_PREFIX\" '--define:process.env.NODE_ENV="development"' --outfile=build/bundle.js
10 | cp -r static/* build/
11 | yarn es-dev-server --port 9091 --root-dir build --app-index index.html
12 |
--------------------------------------------------------------------------------
/templates/browser/react/src/api/index.ts:
--------------------------------------------------------------------------------
1 | export { request } from './request';
2 | export {
3 | {{~ for table in tables ~}}
4 | {{ table.label|dbcore_capitalize }},
5 | {{~ end ~}}
6 | } from './types';
7 |
--------------------------------------------------------------------------------
/templates/browser/react/src/api/request.ts:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | const apiPrefix = window.DBCORE_API_PREFIX; // Substituted by build process
3 |
4 | export async function request(
5 | endpoint: string,
6 | body?: object,
7 | method?: 'POST' | 'GET' | 'DELETE' | 'PUT',
8 | ) {
9 | const req = await window.fetch(apiPrefix + '/{{api.router_prefix}}'+endpoint, {
10 | method: !method ? (body ? 'POST' : 'GET') : method,
11 | body: body ? JSON.stringify(body) : undefined,
12 | headers: body ? {
13 | 'content-type': 'application/json',
14 | } : undefined,
15 | credentials: 'include',
16 | });
17 |
18 | return req.json();
19 | }
20 |
--------------------------------------------------------------------------------
/templates/browser/react/src/api/types.ts:
--------------------------------------------------------------------------------
1 | {{~
2 | func toTypeScriptType
3 | case $0
4 | when "integer", "int", "bigint", "smallint", "decimal", "numeric", "real", "double precision"
5 | "number"
6 | when "boolean"
7 | "boolean"
8 | else
9 | "string"
10 | end
11 | end
12 | ~}}
13 | {{~ for table in tables ~}}
14 | export interface {{ table.label|dbcore_capitalize }} {
15 | {{~ for column in table.columns ~}}
16 | {{ column.name }}{{ if column.nullable }}?{{ end }}: {{ toTypeScriptType column.type }};
17 | {{~ end ~}}
18 | }
19 | {{~ end ~}}
20 |
--------------------------------------------------------------------------------
/templates/browser/react/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export function Button({
4 | children,
5 | type,
6 | }: React.HTMLProps) {
7 | return (
8 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/templates/browser/react/src/components/Form.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Button } from './Button';
4 |
5 | interface Props extends React.HTMLProps {
6 | buttonText: string;
7 | error?: string;
8 | }
9 |
10 | export function Form({
11 | buttonText,
12 | children,
13 | error,
14 | onSubmit,
15 | }: Props) {
16 | return (
17 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/templates/browser/react/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Link } from './Link';
4 |
5 | export function Header() {
6 | return (
7 |