├── .awconfig
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── ci
├── doc
└── overview.edoc
├── elvis.config
├── rebar.config
├── rebar.lock
├── src
├── sr_entities_handler.erl
├── sr_json.erl
├── sr_request.erl
├── sr_single_entity_handler.erl
├── sr_state.erl
├── sumo_rest.app.src
└── sumo_rest_doc.erl
└── test
├── cover.spec
├── sr_echo_request_SUITE.erl
├── sr_elements_SUITE.erl
├── sr_json_SUITE.erl
├── sr_meta_SUITE.erl
├── sr_sessions_SUITE.erl
├── sr_state_SUITE.erl
├── sr_test.app
├── sr_test
├── sr_echo_request.erl
├── sr_echo_request_handler.erl
├── sr_elements.erl
├── sr_elements_handler.erl
├── sr_sessions.erl
├── sr_sessions_handler.erl
├── sr_single_element_handler.erl
├── sr_single_session_handler.erl
└── sr_test.erl
├── test.config
└── utils
└── sr_test_utils.erl
/.awconfig:
--------------------------------------------------------------------------------
1 | ---
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | _build/
2 | compile_commands.json
3 | sumo_rest.d
4 | relx
5 | _rel/
6 | .erlang.mk/
7 | db
8 | Mnesia*
9 | ebin
10 | deps
11 | doc
12 | examples/blog/log
13 | .eunit
14 | *.o
15 | *.beam
16 | *.plt
17 | .rebar
18 | log*/
19 | erl_crash.dump
20 | .erlang.mk.*
21 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: erlang
3 | otp_release:
4 | - 19.2
5 | before_install:
6 | - ./ci before_install "${PWD:?}"/rebar3
7 | install:
8 | - ./ci install "${PWD:?}"/rebar3
9 | script:
10 | - ./ci script "${PWD:?}"/rebar3
11 | cache:
12 | directories:
13 | - .plt
14 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [0.3.4](https://github.com/inaka/sumo_rest/tree/0.3.4) (2017-03-10)
4 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.3.3...0.3.4)
5 |
6 | **Closed issues:**
7 |
8 | - Wrong specs in sr\_single\_entity\_handler \(handle\_put and handle\_patch\) [\#80](https://github.com/inaka/sumo_rest/issues/80)
9 | - Missing option in type spec `sr\_json: json\(\)` [\#79](https://github.com/inaka/sumo_rest/issues/79)
10 |
11 | **Merged pull requests:**
12 |
13 | - \[\#80\] wrong return value on specs [\#82](https://github.com/inaka/sumo_rest/pull/82) ([ferigis](https://github.com/ferigis))
14 | - \[\#79\] missing spec added [\#81](https://github.com/inaka/sumo_rest/pull/81) ([ferigis](https://github.com/ferigis))
15 | - Review all handlers in sample application to be verbose [\#77](https://github.com/inaka/sumo_rest/pull/77) ([lucafavatella](https://github.com/lucafavatella))
16 | - Correct usage of cowboy `compress` option, and clarify type by comment [\#76](https://github.com/inaka/sumo_rest/pull/76) ([lucafavatella](https://github.com/lucafavatella))
17 | - Highlight the role of the `:id` binding in cowboy route [\#75](https://github.com/inaka/sumo_rest/pull/75) ([lucafavatella](https://github.com/lucafavatella))
18 | - Bump sumo\_db to the latest point release of sumo\_db 0.6.x [\#74](https://github.com/inaka/sumo_rest/pull/74) ([lucafavatella](https://github.com/lucafavatella))
19 | - List mnesia as dependency of test application [\#72](https://github.com/inaka/sumo_rest/pull/72) ([lucafavatella](https://github.com/lucafavatella))
20 | - Plant CI [\#71](https://github.com/inaka/sumo_rest/pull/71) ([lucafavatella](https://github.com/lucafavatella))
21 |
22 | ## [0.3.3](https://github.com/inaka/sumo_rest/tree/0.3.3) (2017-02-24)
23 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.3.2...0.3.3)
24 |
25 | **Closed issues:**
26 |
27 | - Version Bump to 0.3.3 [\#68](https://github.com/inaka/sumo_rest/issues/68)
28 | - Add sumo\_rest\_doc:from\_ctx/1 callback [\#66](https://github.com/inaka/sumo_rest/issues/66)
29 | - Version Bump to 0.3.2 [\#64](https://github.com/inaka/sumo_rest/issues/64)
30 |
31 | **Merged pull requests:**
32 |
33 | - \[\#68\] Version Bump to 0.3.3 [\#69](https://github.com/inaka/sumo_rest/pull/69) ([ferigis](https://github.com/ferigis))
34 | - Ferigis.66.add from ctx [\#67](https://github.com/inaka/sumo_rest/pull/67) ([ferigis](https://github.com/ferigis))
35 |
36 | ## [0.3.2](https://github.com/inaka/sumo_rest/tree/0.3.2) (2017-02-15)
37 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.3.1...0.3.2)
38 |
39 | **Closed issues:**
40 |
41 | - POST requests should return 422 instead of 409 when duplication\_conditions fail [\#62](https://github.com/inaka/sumo_rest/issues/62)
42 | - Version Bump to 0.3.1 [\#60](https://github.com/inaka/sumo_rest/issues/60)
43 |
44 | **Merged pull requests:**
45 |
46 | - \[\#64\] Version Bump to 0.3.2 [\#65](https://github.com/inaka/sumo_rest/pull/65) ([ferigis](https://github.com/ferigis))
47 | - \[\#62\] replacing 409 by 422 [\#63](https://github.com/inaka/sumo_rest/pull/63) ([ferigis](https://github.com/ferigis))
48 |
49 | ## [0.3.1](https://github.com/inaka/sumo_rest/tree/0.3.1) (2017-02-02)
50 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.3.0...0.3.1)
51 |
52 | **Closed issues:**
53 |
54 | - DELETE fails if the entity id type is integer [\#58](https://github.com/inaka/sumo_rest/issues/58)
55 |
56 | **Merged pull requests:**
57 |
58 | - \[\#60\] Bump Version to 0.3.1 [\#61](https://github.com/inaka/sumo_rest/pull/61) ([ferigis](https://github.com/ferigis))
59 | - \[\#58\] addressing the issue with non binary ids [\#59](https://github.com/inaka/sumo_rest/pull/59) ([ferigis](https://github.com/ferigis))
60 |
61 | ## [0.3.0](https://github.com/inaka/sumo_rest/tree/0.3.0) (2017-01-30)
62 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.2.1...0.3.0)
63 |
64 | **Closed issues:**
65 |
66 | - Version Bump to 0.3.0 [\#55](https://github.com/inaka/sumo_rest/issues/55)
67 | - Change id/1 by duplication\_conditions/1 to sumo\_rest\_doc [\#53](https://github.com/inaka/sumo_rest/issues/53)
68 | - Error with rebar3 compile if sumo\_rest is fetched from hex.pm [\#49](https://github.com/inaka/sumo_rest/issues/49)
69 |
70 | **Merged pull requests:**
71 |
72 | - \[\#55\] Version Bump to 0.3.0 [\#57](https://github.com/inaka/sumo_rest/pull/57) ([ferigis](https://github.com/ferigis))
73 | - \[\#53\] update the README.md accordingly [\#56](https://github.com/inaka/sumo_rest/pull/56) ([ferigis](https://github.com/ferigis))
74 | - \[\#53\] replacing id/1 callback by duplication\_conditions/1 [\#54](https://github.com/inaka/sumo_rest/pull/54) ([ferigis](https://github.com/ferigis))
75 |
76 | ## [0.2.1](https://github.com/inaka/sumo_rest/tree/0.2.1) (2016-09-14)
77 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.2.0...0.2.1)
78 |
79 | **Closed issues:**
80 |
81 | - Version Bump to 0.2.1 [\#47](https://github.com/inaka/sumo_rest/issues/47)
82 | - Error results from sumo\_rest\_doc's update callback are improperly handled [\#44](https://github.com/inaka/sumo_rest/issues/44)
83 | - Add "\_=\>\_" to the state\(\) types [\#43](https://github.com/inaka/sumo_rest/issues/43)
84 |
85 | **Merged pull requests:**
86 |
87 | - \[\#47\] Version Bump to 0.2.1 [\#48](https://github.com/inaka/sumo_rest/pull/48) ([ferigis](https://github.com/ferigis))
88 | - \[\#44\] update error handled with sr\_json:error/1 [\#46](https://github.com/inaka/sumo_rest/pull/46) ([ferigis](https://github.com/ferigis))
89 | - \[\#43\] state\(\) types fixed [\#45](https://github.com/inaka/sumo_rest/pull/45) ([ferigis](https://github.com/ferigis))
90 |
91 | ## [0.2.0](https://github.com/inaka/sumo_rest/tree/0.2.0) (2016-09-12)
92 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.1.2...0.2.0)
93 |
94 | **Fixed bugs:**
95 |
96 | - Properly differentiate between sumo\_db's models and modules [\#40](https://github.com/inaka/sumo_rest/pull/40) ([elbrujohalcon](https://github.com/elbrujohalcon))
97 |
98 | **Closed issues:**
99 |
100 | - Move this project to Rebar3 [\#41](https://github.com/inaka/sumo_rest/issues/41)
101 | - Upgrade dependencies [\#38](https://github.com/inaka/sumo_rest/issues/38)
102 | - rebar3 compile -\> failing [\#30](https://github.com/inaka/sumo_rest/issues/30)
103 | - Update repo and make it ready for hex.pm [\#28](https://github.com/inaka/sumo_rest/issues/28)
104 | - Hex Package [\#9](https://github.com/inaka/sumo_rest/issues/9)
105 | - Use query-string for filtering [\#8](https://github.com/inaka/sumo_rest/issues/8)
106 | - Increase swagger integration [\#7](https://github.com/inaka/sumo_rest/issues/7)
107 |
108 | **Merged pull requests:**
109 |
110 | - \[\#38\] updated sumo\_db dep and now it is working with OTP-19 [\#39](https://github.com/inaka/sumo_rest/pull/39) ([ferigis](https://github.com/ferigis))
111 | - Updated readme [\#37](https://github.com/inaka/sumo_rest/pull/37) ([HernanRivasAcosta](https://github.com/HernanRivasAcosta))
112 | - Handle params in query-string [\#35](https://github.com/inaka/sumo_rest/pull/35) ([zgbjgg](https://github.com/zgbjgg))
113 | - resolves \#7 [\#33](https://github.com/inaka/sumo_rest/pull/33) ([zsoci](https://github.com/zsoci))
114 | - \[Fix \#30\] Fix rebar3 compilation by updating swagger, trails and sumo\_db dependencies [\#31](https://github.com/inaka/sumo_rest/pull/31) ([harenson](https://github.com/harenson))
115 | - \[Fix \#28\] Update dependencies; Update erlang.mk; Add ruleset to elvis config; Add rebar.config file [\#29](https://github.com/inaka/sumo_rest/pull/29) ([harenson](https://github.com/harenson))
116 | - Version Bump to 0.2.0 [\#42](https://github.com/inaka/sumo_rest/pull/42) ([elbrujohalcon](https://github.com/elbrujohalcon))
117 | - Make the project rebar3 compatible [\#6](https://github.com/inaka/sumo_rest/pull/6) ([elbrujohalcon](https://github.com/elbrujohalcon))
118 |
119 | ## [0.1.2](https://github.com/inaka/sumo_rest/tree/0.1.2) (2016-03-11)
120 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.1.1...0.1.2)
121 |
122 | **Closed issues:**
123 |
124 | - Bump version to 0.1.2 [\#25](https://github.com/inaka/sumo_rest/issues/25)
125 | - Missing iso8601 in sumo\_rest.app.src applications list [\#24](https://github.com/inaka/sumo_rest/issues/24)
126 |
127 | **Merged pull requests:**
128 |
129 | - \[Fix \#25\] Bump version to 0.1.2 [\#27](https://github.com/inaka/sumo_rest/pull/27) ([harenson](https://github.com/harenson))
130 | - \[Fix \#24\] Add iso8601 to the app.src applications list [\#26](https://github.com/inaka/sumo_rest/pull/26) ([harenson](https://github.com/harenson))
131 |
132 | ## [0.1.1](https://github.com/inaka/sumo_rest/tree/0.1.1) (2015-12-15)
133 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.1.0...0.1.1)
134 |
135 | **Closed issues:**
136 |
137 | - Version Bump to 0.1.1 [\#22](https://github.com/inaka/sumo_rest/issues/22)
138 | - Path variables are not take in account when building the location header [\#20](https://github.com/inaka/sumo_rest/issues/20)
139 | - Link to sr\_test.app file is broken [\#18](https://github.com/inaka/sumo_rest/issues/18)
140 |
141 | **Merged pull requests:**
142 |
143 | - \[Fix \#22\] Bump version to 0.1.1 [\#23](https://github.com/inaka/sumo_rest/pull/23) ([harenson](https://github.com/harenson))
144 | - \[Fix \#20\] Rename uri\_path/1 to location/2 and change its functionality... [\#21](https://github.com/inaka/sumo_rest/pull/21) ([harenson](https://github.com/harenson))
145 | - \[Fix \#18\] repair broken link [\#19](https://github.com/inaka/sumo_rest/pull/19) ([elbrujohalcon](https://github.com/elbrujohalcon))
146 |
147 | ## [0.1.0](https://github.com/inaka/sumo_rest/tree/0.1.0) (2015-12-02)
148 | [Full Changelog](https://github.com/inaka/sumo_rest/compare/0.0.1...0.1.0)
149 |
150 | **Fixed bugs:**
151 |
152 | - Invalid Content-Type for error responses [\#14](https://github.com/inaka/sumo_rest/pull/14) ([elbrujohalcon](https://github.com/elbrujohalcon))
153 | - Add 'patch' to the allowed methods in atom\_to\_method [\#13](https://github.com/inaka/sumo_rest/pull/13) ([elbrujohalcon](https://github.com/elbrujohalcon))
154 |
155 | **Closed issues:**
156 |
157 | - Fulfil the open-source checklist [\#1](https://github.com/inaka/sumo_rest/issues/1)
158 | - Create a sample application [\#5](https://github.com/inaka/sumo_rest/issues/5)
159 |
160 | **Merged pull requests:**
161 |
162 | - Version bump to 0.1.0 [\#17](https://github.com/inaka/sumo_rest/pull/17) ([elbrujohalcon](https://github.com/elbrujohalcon))
163 | - README [\#16](https://github.com/inaka/sumo_rest/pull/16) ([elbrujohalcon](https://github.com/elbrujohalcon))
164 | - \[\#1\] Initial step [\#15](https://github.com/inaka/sumo_rest/pull/15) ([elbrujohalcon](https://github.com/elbrujohalcon))
165 | - Update cowboy-swagger dep and remove the hack [\#12](https://github.com/inaka/sumo_rest/pull/12) ([elbrujohalcon](https://github.com/elbrujohalcon))
166 | - Reach 100% Code Coverage on Tests [\#10](https://github.com/inaka/sumo_rest/pull/10) ([elbrujohalcon](https://github.com/elbrujohalcon))
167 | - Create sumo\_single\_entity\_handler [\#4](https://github.com/inaka/sumo_rest/pull/4) ([elbrujohalcon](https://github.com/elbrujohalcon))
168 | - Create sumo\_entities\_handler [\#3](https://github.com/inaka/sumo_rest/pull/3) ([elbrujohalcon](https://github.com/elbrujohalcon))
169 | - Initial project setup [\#2](https://github.com/inaka/sumo_rest/pull/2) ([elbrujohalcon](https://github.com/elbrujohalcon))
170 |
171 | ## [0.0.1](https://github.com/inaka/sumo_rest/tree/0.0.1) (2015-11-28)
172 | **Merged pull requests:**
173 |
174 | - Release Version 0.0.1 [\#11](https://github.com/inaka/sumo_rest/pull/11) ([elbrujohalcon](https://github.com/elbrujohalcon))
175 |
176 |
177 |
178 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2015 Erlang Solutions Ltd.
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
203 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sumo Rest
2 |
3 | [](https://travis-ci.org/inaka/sumo_rest)
4 |
5 |
6 |
7 | Generic **Cowboy** handlers to work with **Sumo DB**
8 |
9 | ## Introduction
10 | We, at Inaka, build our RESTful servers on top of [cowboy](https://github.com/ninenines/cowboy). We use [sumo_db](https://github.com/inaka/sumo_db) to manage our persistence and [trails](https://github.com/inaka/cowboy-trails) together with [cowboy-swagger](https://github.com/inaka/cowboy-swagger) for documentation.
11 |
12 | Soon enough, we realized that we were duplicating code everywhere. Not every endpoint in our APIs is just a CRUD for some entity, but there are definitely lots of them in every server. As an example, most of our servers provide something like the following list of endpoints:
13 |
14 | * `GET /users` - Returns the list of users
15 | * `POST /users` - Creates a new user
16 | * `PUT /users/:id` or `PATCH /users/:id` - Updates a user
17 | * `DELETE /users/:id` - Deletes a user
18 | * `GET /users/:id` - Retrieves an individual user
19 |
20 | To avoid (or at least reduce) such duplication, we started using [mixer](https://github.com/chef/mixer). That way, we can have a *base_handler* in each application where all the common handler logic lives.
21 |
22 | Eventually, all applications shared that same *base_handler*, so we decided to abstract that even further. Into its own app: **sumo_rest**.
23 |
24 | ## Architecture
25 | This project dependency tree is a great way to show the architecture behind it.
26 |
27 | 
28 |
29 | As you'll see below, **Sumo Rest** gives you _base handlers_ that you can use on your **Cowboy** server to manage your **Sumo DB** entities easily. You just need to define your routes using **Trails** and provide proper metadata for each of them. In particular, you need to provide the same basic metadata **Swagger** requires. You can manually use the base handlers and call each of their functions when you need them, but you can also use **Mixer** to just _bring_ their functions to your own handlers easily.
30 |
31 | ## Usage
32 | In a nutshell, **Sumo Rest** provides 2 cowboy rest handlers:
33 |
34 | - [`sr_entities_handler`](src/sr_entities_handler.erl) that provides an implementation for
35 | + `POST /entities` - to create a new entity
36 | + `GET /entitites` - to retrieve the list of all entities
37 | - [`sr_single_entity_handler`](src/sr_single_entity_handler.erl) that provides implementation for
38 | + `GET /entities/:id` - to retrieve an entity
39 | + `PUT /entities/:id` - to update (or create) an entity
40 | + `PATCH /entities/:id` - to update an entity
41 | + `DELETE /entities/:id` - to delete an entity
42 |
43 | (Of course, the uris for those endpoints will not be exactly those, you have to define what _entities_ you want to manage.)
44 |
45 | To use them you first have to define your models, by implementing the behaviours `sumo_doc` (from **Sumo DB**) and [`sumo_rest_doc`](src/sumo_rest_doc.erl).
46 |
47 | Then you have to create a module that implements the `trails_handler` behaviour (from **Trails**) and _mix in_ that module all the functions that you need from the provided handlers.
48 |
49 | ## A Basic Example
50 | You can find a very basic example of the usage of this app in the [tests](test/sr_test).
51 |
52 | The app used for the tests (`sr_test`), makes no sense at all. Don't worry about that. It's just there to provide examples of usage (and of course to run the tests). It basically manages 2 totally independent entities:
53 | - _elements_: members of an extremely naïve key/value store
54 | - _sessions_: poorly-designed user sessions :trollface:
55 |
56 | Let me walk you through the process of creating such a simple app.
57 |
58 | ### The application definition
59 | In [sr_test.app](test/sr_test.app) file you'll find the usual stuff. The only particular pieces are:
60 |
61 | * The list of `applications`, which includes `cowboy`, `katana`, `cowboy_swagger` and `sumo_db`.
62 | * The list of `start_phases`. This is not a requirement, but we've found this is a nice way of getting **Sumo DB** up and running before **Cowboy** starts listening:
63 | ```erlang
64 | { start_phases
65 | , [ {create_schema, []}
66 | , {start_cowboy_listeners, []}
67 | ]
68 | }
69 | ```
70 |
71 | ### The configuration
72 | In [test.config](test/test.config) we added the required configuration for the different apps to work:
73 |
74 | #### Swagger
75 | We just defined the minimum required properties:
76 | ```erlang
77 | , { cowboy_swagger
78 | , [ { global_spec
79 | , #{ swagger => "2.0"
80 | , info => #{title => "SumoRest Test API"}
81 | , basePath => ""
82 | }
83 | }
84 | ]
85 | }
86 | ```
87 |
88 | #### Mnesia
89 | We've chosen **Mnesia** as our backend, so we just enabled debug on it (not a requirement, but a nice thing to have on development environments):
90 | ```erlang
91 | , { mnesia
92 | , [{debug, true}]
93 | }
94 | ```
95 |
96 | #### Sumo DB
97 | **Sumo DB**'s **Mnesia** backend/store is really easy to set up. We will just have 2 models: _elements_ and _sessions_. We will store them both on **Mnesia**:
98 | ```erlang
99 | , { sumo_db
100 | , [ {wpool_opts, [{overrun_warning, 100}]}
101 | , {log_queries, true}
102 | , {query_timeout, 30000}
103 | , {storage_backends, []}
104 | , {stores, [{sr_store_mnesia, sumo_store_mnesia, [{workers, 10}]}]}
105 | , { docs
106 | , [ {elements, sr_store_mnesia, #{module => sr_elements}}
107 | , {sessions, sr_store_mnesia, #{module => sr_sessions}}
108 | ]
109 | }
110 | , {events, []}
111 | ]
112 | }
113 | ```
114 |
115 | #### SR Test
116 | Finally we add some extremely naïve configuration to our own app. In our case, just a list of users we'll use for authentication purposes (:warning: **Do NOT do this at home, kids** :warning:):
117 | ```erlang
118 | , { sr_test
119 | , [ {users, [{<<"user1">>, <<"pwd1">>}, {<<"user2">>, <<"pwd2">>}]}
120 | ]
121 | }
122 | ```
123 |
124 | ### The application module
125 | The next step is to come up with the main application module: [sr_test](test/sr_test/sr_test.erl). The interesting bits are all in the start phases.
126 |
127 | #### `create_schema`
128 | For **Sumo DB** to work, we just need to make sure we create the schema. We need to do a little trick to setup **Mnesia** though, because for `create_schema` to properly work, **Mnesia** has to be stopped:
129 | ```erlang
130 | start_phase(create_schema, _StartType, []) ->
131 | _ = application:stop(mnesia),
132 | Node = node(),
133 | case mnesia:create_schema([Node]) of
134 | ok -> ok;
135 | {error, {Node, {already_exists, Node}}} -> ok
136 | end,
137 | {ok, _} = application:ensure_all_started(mnesia),
138 | sumo:create_schema();
139 | ```
140 |
141 | #### `start_cowboy_listeners`
142 | Since we're using **Trails**, we can let each module define its own ~~routes~~ trails. And, since we're using a single host we can use the fancy helper that comes with **Trails**:
143 | ```erlang
144 | Handlers =
145 | [ sr_elements_handler
146 | , sr_single_element_handler
147 | , sr_sessions_handler
148 | , sr_single_session_handler
149 | , cowboy_swagger_handler
150 | ],
151 | Routes = trails:trails(Handlers),
152 | trails:store(Routes),
153 | Dispatch = trails:single_host_compile(Routes),
154 | ```
155 | It's crucial that we _store_ the trails. Otherwise, **Sumo Rest** will not be able to find them later.
156 |
157 | Then, we start our **Cowboy** server:
158 | ```erlang
159 | TransOpts = [{port, 4891}],
160 | ProtoOpts = %% cowboy_protocol:opts()
161 | [{compress, true}, {env, [{dispatch, Dispatch}]}],
162 | case cowboy:start_http(sr_test_server, 1, TransOpts, ProtoOpts) of
163 | {ok, _} -> ok;
164 | {error, {already_started, _}} -> ok
165 | end.
166 | ```
167 |
168 | ### The Models
169 | The next step is to define our models (i.e. the entities our system will manage). We use a module for each model and all of them implement the required behaviours.
170 |
171 | #### Elements
172 | [Elements](test/sr_test/sr_elements.erl) are simple key/value pairs.
173 | ```erlang
174 | -type key() :: integer().
175 | -type value() :: binary() | iodata().
176 |
177 | -opaque element() ::
178 | #{ key => key()
179 | , value => value()
180 | , created_at => calendar:datetime()
181 | , updated_at => calendar:datetime()
182 | }.
183 | ```
184 |
185 | `sumo_doc` requires us to add the schema, sleep and wakeup functions. Since we'll use maps for our internal representation (just like **Sumo DB** does), they're trivial:
186 | ```erlang
187 | -spec sumo_schema() -> sumo:schema().
188 | sumo_schema() ->
189 | sumo:new_schema(elements,
190 | [ sumo:new_field(key, string, [id, not_null])
191 | , sumo:new_field(value, string, [not_null])
192 | , sumo:new_field(created_at, datetime, [not_null])
193 | , sumo:new_field(updated_at, datetime, [not_null])
194 | ]).
195 |
196 | -spec sumo_sleep(element()) -> sumo:doc().
197 | sumo_sleep(Element) -> Element.
198 |
199 | -spec sumo_wakeup(sumo:doc()) -> element().
200 | sumo_wakeup(Element) -> Element.
201 | ```
202 |
203 | `sumo_rest_doc` on the other hand requires functions to convert to and from json (which should also validate user input):
204 | ```erlang
205 | -spec to_json(element()) -> sumo_rest_doc:json().
206 | to_json(Element) ->
207 | #{ key => maps:get(key, Element)
208 | , value => maps:get(value, Element)
209 | , created_at => sr_json:encode_date(maps:get(created_at, Element))
210 | , updated_at => sr_json:encode_date(maps:get(updated_at, Element))
211 | }.
212 | ```
213 |
214 | In order to convert from json we have two options: `from_json` or `from_ctx`. The difference is `from_json` accepts only a json body as a parameter, `from_ctx` receive a `context` structure which has the entire request and handler state besides the json body. We will see a `from_ctx` example in `sessions` section
215 | ```erlang
216 | -spec from_json(sumo_rest_doc:json()) -> {ok, element()} | {error, iodata()}.
217 | from_json(Json) ->
218 | Now = sr_json:encode_date(calendar:universal_time()),
219 | try
220 | { ok
221 | , #{ key => maps:get(<<"key">>, Json)
222 | , value => maps:get(<<"value">>, Json)
223 | , created_at =>
224 | sr_json:decode_date(maps:get(<<"created_at">>, Json, Now))
225 | , updated_at =>
226 | sr_json:decode_date(maps:get(<<"updated_at">>, Json, Now))
227 | }
228 | }
229 | catch
230 | _:{badkey, Key} ->
231 | {error, <<"missing field: ", Key/binary>>}
232 | end.
233 | ```
234 |
235 | We also need to provide an `update` function for `PUT` and `PATCH`:
236 | ```erlang
237 | -spec update(element(), sumo_rest_doc:json()) ->
238 | {ok, element()} | {error, iodata()}.
239 | update(Element, Json) ->
240 | try
241 | NewValue = maps:get(<<"value">>, Json),
242 | UpdatedElement =
243 | Element#{value := NewValue, updated_at := calendar:universal_time()},
244 | {ok, UpdatedElement}
245 | catch
246 | _:{badkey, Key} ->
247 | {error, <<"missing field: ", Key/binary>>}
248 | end.
249 | ```
250 |
251 | For **Sumo Rest** to provide urls to the callers, we need to specify the location URL:
252 | ```erlang
253 | -spec location(element(), sumo_rest_doc:path()) -> binary().
254 | location(Element, Path) -> iolist_to_binary([Path, "/", key(Element)]).
255 | ```
256 |
257 | To let **Sumo Rest** avoid duplicate keys (and return `422 Conflict` in that case), we provide the optional callback `duplication_conditions/1`:
258 | ```erlang
259 | -spec duplication_conditions(element()) -> sumo_rest_doc:duplication_conditions().
260 | duplication_conditions(Element) -> [{key, '==', key(Element)}].
261 | ```
262 |
263 | If your model has an `id` type different than integer, string or binary you have to implement `id_from_binding/1`. That function is needed in order to convert the `id` from `binary()` to your type. There is an example at `sr_elements` module for our test coverage. It only converts to `integer()` but that is the general idea behind that function.
264 | ```erlang
265 | -spec id_from_binding(binary()) -> key().
266 | id_from_binding(BinaryId) ->
267 | try binary_to_integer(BinaryId) of
268 | Id -> Id
269 | catch
270 | error:badarg -> -1
271 | end.
272 | ```
273 |
274 | The rest of the functions in the module are just helpers, particularly useful for our tests.
275 |
276 | #### Sessions
277 | [Sessions](test/sr_test/sr_sessions.erl) are very similar to elements. One difference is that session ids (unlike element keys) are auto-generated by the mnesia store. Therefore they're initially `undefined`. We don't need to provide a `duplication_conditions/1` function in this case since we don't need to avoid duplicates.
278 |
279 | The most important difference with elements is sessions does't implement `from_json` callback. Remember, `from_json` only accepts the request body in json format. In sessions we also need the logged user in order to build our session. In this case we implement `from_ctx` instead of `from_json` since it accepts the entire request and the handler's state. That information is encapsulated in a `context` structure.
280 |
281 | This is how the `context`'s spec looks like. It is composed by a `sr_request:req()` and a `sr_state:state()` structures. Modules `sr_state` and `sr_request` are available in order to manipulate them.
282 | ```erlang
283 | -type context() :: #{req := sr_request:req(), state := sr_state:state()}
284 |
285 | .. In sr_request.erl ...
286 |
287 | -opaque req() ::
288 | #{ body => sr_json:json()
289 | , headers := [{binary(), iodata()}]
290 | , path := binary()
291 | , bindings := #{atom() => any()}
292 | }.
293 |
294 | ... In sr_state.erl ...
295 |
296 | -opaque state() ::
297 | #{ opts := sr_state:options()
298 | , id => binary()
299 | , entity => sumo:user_doc()
300 | , module := module()
301 | , user_opts := map()
302 | }.
303 | ```
304 |
305 | And this is the `from_ctx` implementation
306 | ```erlang
307 | -spec from_ctx(sumo_rest_doc:context()) -> {ok, session()} | {error, iodata()}.
308 | from_ctx(#{req := SrRequest, state := State}) ->
309 | Json = sr_request:body(SrRequest),
310 | {User, _} = sr_state:retrieve(user, State, undefined),
311 | case from_json_internal(Json) of
312 | {ok, Session} -> {ok, user(Session, User)};
313 | MissingField -> MissingField
314 | end.
315 |
316 | ```
317 |
318 | ### The Handlers
319 | Now, the juicy part: The cowboy handlers. We have 4, two of them built on top of `sr_entitites_handler` and the other two built on `sr_single_entity_handler`.
320 |
321 | #### Elements
322 | [sr_elements_handler](test/sr_test/sr_elements_handler.erl) is built on `sr_entities_handler` and handles the path `"/elements"`. As you can see, the code is really simple.
323 |
324 | First we _mix in_ the functions from `sr_entities_handler`:
325 | ```erlang
326 | -include_lib("mixer/include/mixer.hrl").
327 | -mixin([{ sr_entities_handler
328 | , [ init/3
329 | , rest_init/2
330 | , allowed_methods/2
331 | , resource_exists/2
332 | , content_types_accepted/2
333 | , content_types_provided/2
334 | , handle_get/2
335 | , handle_post/2
336 | ]
337 | }]).
338 | ```
339 |
340 | Then, we only need to write the documentation for this module, and provide the proper `Opts` and that's all:
341 | ```erlang
342 | -spec trails() -> trails:trails().
343 | trails() ->
344 | RequestBody =
345 | #{ name => <<"request body">>
346 | , in => body
347 | , description => <<"request body (as json)">>
348 | , required => true
349 | },
350 | Metadata =
351 | #{ get =>
352 | #{ tags => ["elements"]
353 | , description => "Returns the list of elements"
354 | , produces => ["application/json"]
355 | }
356 | , post =>
357 | #{ tags => ["elements"]
358 | , description => "Creates a new element"
359 | , consumes => ["application/json"]
360 | , produces => ["application/json"]
361 | , parameters => [RequestBody]
362 | }
363 | },
364 | Path = "/elements",
365 | Opts = #{ path => Path
366 | , model => elements
367 | , verbose => true
368 | },
369 | [trails:trail(Path, ?MODULE, Opts, Metadata)].
370 | ```
371 | The `Opts` here include the trails path (so it can be found later) and the model behind it.
372 |
373 | And there you go, **_no more code!_**
374 |
375 | [`sr_single_element_handler`](test/sr_test/sr_single_element_handler.erl) is analogous but it's based on `sr_single_entity_handler`.
376 |
377 | #### Sessions
378 | [sr_sessions_handler](test/sr_test/sr_sessions_handler.erl) shows you what happens when you need to steer away from the default implementations in **Sumo Rest**. It's as easy as defining your own functions instead of _mixing_ them _in_ from the base handlers.
379 |
380 | In this case we needed authentication, so we added an implementation for `is_authorized`:
381 | ```erlang
382 | -spec is_authorized(cowboy_req:req(), state()) ->
383 | {boolean(), cowboy_req:req(), state()}.
384 | is_authorized(Req, State) ->
385 | case get_authorization(Req) of
386 | {not_authenticated, Req1} ->
387 | {{false, auth_header()}, Req1, State};
388 | {User, Req1} ->
389 | Users = application:get_env(sr_test, users, []),
390 | case lists:member(User, Users) of
391 | true -> {true, Req1, State#{user => User}};
392 | false ->
393 | ct:pal("Invalid user ~p not in ~p", [User, Users]),
394 | {{false, auth_header()}, Req1, State}
395 | end
396 | end.
397 | ```
398 |
399 | Finally, we did something similar in [`sr_single_session_handler`](test/sr_test/sr_single_session_handler.erl). We needed the same authentication mechanism, so we just _mix_ it _in_:
400 | ```erlang
401 | -mixin([{ sr_sessions_handler
402 | , [ is_authorized/2
403 | ]
404 | }]).
405 | ```
406 |
407 | But we needed to prevent users from accessing other user's sessions, so we implemented `forbidden/2`:
408 | ```erlang
409 | -spec forbidden(cowboy_req:req(), state()) ->
410 | {boolean(), cowboy_req:req(), state()}.
411 | forbidden(Req, State) ->
412 | #{user := {User, _}, id := Id} = State,
413 | case sumo:fetch(sessions, Id) of
414 | notfound -> {false, Req, State};
415 | Session -> {User =/= sr_sessions:user(Session), Req, State}
416 | end.
417 | ```
418 |
419 | And, since sessions can not be created with `PUT` (because their keys are auto-generated):
420 | ```erlang
421 | -spec is_conflict(cowboy_req:req(), state()) ->
422 | {boolean(), cowboy_req:req(), state()}.
423 | is_conflict(Req, State) ->
424 | {not maps:is_key(entity, State), Req, State}.
425 | ```
426 |
427 | ## A Full-Fledged App
428 | For a more elaborated example on how to use this library, please check [lsl](https://github.com/inaka/lsl).
429 |
430 | ---
431 |
432 | ## Contact Us
433 | If you find any **bugs** or have a **problem** while using this library, please
434 | [open an issue](https://github.com/inaka/sumo_rest/issues/new) in this repo
435 | (or a pull request :)).
436 |
437 | And you can check all of our open-source projects at [inaka.github.io](http://inaka.github.io).
438 |
--------------------------------------------------------------------------------
/ci:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -ev # Ref https://docs.travis-ci.com/user/customizing-the-build/#Implementing-Complex-Build-Steps
4 |
5 | case "${1:?}" in
6 | before_install)
7 | ## Travis CI does not support rebar3 yet. See https://github.com/travis-ci/travis-ci/issues/6506#issuecomment-275189490
8 | Rebar3="${2:?}"
9 | curl -f -L -o "${Rebar3:?}" https://github.com/erlang/rebar3/releases/download/3.3.5/rebar3
10 | chmod +x "${Rebar3:?}"
11 | ;;
12 | install)
13 | Rebar3="${2:?}"
14 | "${Rebar3:?}" deps
15 | "${Rebar3:?}" dialyzer -u true -s false
16 | ;;
17 | script)
18 | Rebar3="${2:?}"
19 | "${Rebar3:?}" ct
20 | ;;
21 | esac
22 |
--------------------------------------------------------------------------------
/doc/overview.edoc:
--------------------------------------------------------------------------------
1 | @author Brujo Benavides
2 | @copyright 2015 Erlang Solutions Ltd.
3 | @version 0.0.1
4 | @title Sumo Rest
5 | @doc Sumo Rest gives you generic Cowboy
6 | handlers to work with Sumo DB
7 | @reference Check Github
8 | for more information.
9 |
--------------------------------------------------------------------------------
/elvis.config:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | elvis,
4 | [
5 | {config,
6 | [#{dirs => ["src"],
7 | filter => "*.erl",
8 | rules => [{elvis_style, god_modules, #{limit => 35}},
9 | {elvis_style, invalid_dynamic_call, disable}],
10 | ruleset => erl_files
11 | },
12 | #{dirs => ["test", "test/*"],
13 | filter => "*.erl",
14 | rules => [{elvis_style, god_modules, #{limit => 35}},
15 | {elvis_style, no_debug_call, disable}],
16 | ruleset => erl_files
17 | },
18 | #{dirs => ["."],
19 | filter => "rebar.config",
20 | %% Disabled until inaka/elvis#395 is fixed.
21 | rules => [{elvis_project, protocol_for_deps_rebar, disable}],
22 | ruleset => rebar_config
23 | },
24 | #{dirs => ["."],
25 | filter => "elvis.config",
26 | ruleset => elvis_config
27 | }
28 | ]
29 | }
30 | ]
31 | }
32 | ].
33 |
--------------------------------------------------------------------------------
/rebar.config:
--------------------------------------------------------------------------------
1 | %% == Erlang Compiler ==
2 |
3 | {erl_opts, [
4 | warn_unused_vars,
5 | warn_export_all,
6 | warn_shadow_vars,
7 | warn_unused_import,
8 | warn_unused_function,
9 | warn_bif_clash,
10 | warn_unused_record,
11 | warn_deprecated_function,
12 | warn_obsolete_guard,
13 | strict_validation,
14 | warn_export_vars,
15 | warn_exported_vars,
16 | warn_missing_spec,
17 | warn_untyped_record,
18 | debug_info
19 | ]}.
20 |
21 | %% == Dependencies ==
22 |
23 | {profiles, [
24 | {test, [
25 | {deps, [
26 | {katana_test, "0.1.1"},
27 | {shotgun, "0.2.3"}
28 | ]}
29 | ]},
30 | {shell, [
31 | {deps, [
32 | {sync, {git, "https://github.com/rustyio/sync.git", {ref, "9c78e7b"}}}
33 | ]}
34 | ]}
35 | ]}.
36 |
37 | {deps,
38 | [
39 | {cowboy, "1.0.4"},
40 | {jsx, "2.8.2"},
41 | {trails, "0.2.0"},
42 | {cowboy_swagger, "1.2.2"},
43 | {mixer, "0.1.5", {pkg, inaka_mixer}},
44 | {iso8601, "1.1.2", {pkg, inaka_iso8601}},
45 | {sumo_db, "0.6.4"}
46 | ]
47 | }.
48 |
49 | %% == Common Test ==
50 |
51 | {ct_compile_opts, [
52 | warn_unused_vars,
53 | warn_export_all,
54 | warn_shadow_vars,
55 | warn_unused_import,
56 | warn_unused_function,
57 | warn_bif_clash,
58 | warn_unused_record,
59 | warn_deprecated_function,
60 | warn_obsolete_guard,
61 | strict_validation,
62 | warn_export_vars,
63 | warn_exported_vars,
64 | warn_missing_spec,
65 | warn_untyped_record,
66 | debug_info
67 | ]}.
68 |
69 | {ct_opts, [
70 | {sys_config, ["test/test.config"]}, {verbose, true}
71 | ]}.
72 |
73 | %% == Cover ==
74 |
75 | {cover_enabled, true}.
76 |
77 | {cover_opts, [verbose]}.
78 |
79 | %% == EDoc ==
80 |
81 | {edoc_opts, [
82 | {report_missing_types, true},
83 | {source_path, ["src"]},
84 | {report_missing_types, true},
85 | {todo, true},
86 | {packages, false},
87 | {subpackages, false}
88 | ]}.
89 |
90 | %% == Xref ==
91 | {xref_checks, [undefined_function_calls, locals_not_used, deprecated_function_calls]}.
92 |
93 | %% == Dialyzer ==
94 |
95 | {dialyzer, [
96 | {plt_apps, top_level_deps},
97 | {plt_location, local}
98 | ]}.
99 |
--------------------------------------------------------------------------------
/rebar.lock:
--------------------------------------------------------------------------------
1 | {"1.1.0",
2 | [{<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.0.4">>},0},
3 | {<<"cowboy_swagger">>,{pkg,<<"cowboy_swagger">>,<<"1.2.2">>},0},
4 | {<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},1},
5 | {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.8">>},2},
6 | {<<"iso8601">>,{pkg,<<"inaka_iso8601">>,<<"1.1.2">>},0},
7 | {<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.2">>},0},
8 | {<<"lager">>,{pkg,<<"lager">>,<<"3.2.1">>},1},
9 | {<<"mixer">>,{pkg,<<"inaka_mixer">>,<<"0.1.5">>},0},
10 | {<<"quickrand">>,{pkg,<<"quickrand">>,<<"1.5.4">>},2},
11 | {<<"ranch">>,{pkg,<<"ranch">>,<<"1.3.2">>},1},
12 | {<<"sumo_db">>,{pkg,<<"sumo_db">>,<<"0.6.4">>},0},
13 | {<<"trails">>,{pkg,<<"trails">>,<<"0.2.0">>},0},
14 | {<<"uuid">>,{pkg,<<"uuid_erl">>,<<"1.5.2-rc1">>},1},
15 | {<<"worker_pool">>,{pkg,<<"worker_pool">>,<<"2.0.1">>},1}]}.
16 | [
17 | {pkg_hash,[
18 | {<<"cowboy">>, <<"A324A8DF9F2316C833A470D918AAF73AE894278B8AA6226CE7A9BF699388F878">>},
19 | {<<"cowboy_swagger">>, <<"758F550A5C7D781BA20A1A58B57595BD0A42742208357F31C902BF97D359DBE9">>},
20 | {<<"cowlib">>, <<"9D769A1D062C9C3AC753096F868CA121E2730B9A377DE23DEC0F7E08B1DF84EE">>},
21 | {<<"goldrush">>, <<"2024BA375CEEA47E27EA70E14D2C483B2D8610101B4E852EF7F89163CDB6E649">>},
22 | {<<"iso8601">>, <<"6D84BBA9641FA39802E6B53C57E0B61B2F61BF8E81C112356B57BE8DA31DE771">>},
23 | {<<"jsx">>, <<"7ACC7D785B5ABE8A6E9ADBDE926A24E481F29956DD8B4DF49E3E4E7BCC92A018">>},
24 | {<<"lager">>, <<"EEF4E18B39E4195D37606D9088EA05BF1B745986CF8EC84F01D332456FE88D17">>},
25 | {<<"mixer">>, <<"754630C0E60221B23E4D83ADF6E789A8B283855E23F9391EEC40980E6E800486">>},
26 | {<<"quickrand">>, <<"47ADD4755CC5F209CBEFFD6F47C84061196CD7FAD99FD8FD12418EB0D06B939D">>},
27 | {<<"ranch">>, <<"E4965A144DC9FBE70E5C077C65E73C57165416A901BD02EA899CFD95AA890986">>},
28 | {<<"sumo_db">>, <<"BA655B9E83FDB6C46766EEBF0F01277ED1FA0CC642A6049862097ADD8AFE0080">>},
29 | {<<"trails">>, <<"8A26C8F32B2A4D317830FAF55F034CC532D849547686806B83063AA1F65213D2">>},
30 | {<<"uuid">>, <<"D4022AB3F4F1A28E86EA15D4075CB0C57EC908D8AF1CA2E8AF28AA815EF93C3A">>},
31 | {<<"worker_pool">>, <<"B90273074898FA89434317991E00884DBBAFFAB5BFD964A7586317CD16FB18D4">>}]}
32 | ].
33 |
--------------------------------------------------------------------------------
/src/sr_entities_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Base GET|POST /[entities] implementation
2 | -module(sr_entities_handler).
3 |
4 | -export([ init/3
5 | , rest_init/2
6 | , allowed_methods/2
7 | , resource_exists/2
8 | , content_types_accepted/2
9 | , content_types_provided/2
10 | , handle_get/2
11 | , handle_post/2
12 | ]).
13 | -export([ announce_req/2
14 | , handle_post/3
15 | ]).
16 |
17 | -type options() :: sr_state:options().
18 | -type state() :: sr_state:state().
19 | -export_type([state/0, options/0]).
20 |
21 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
22 | %%% Cowboy Callbacks
23 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
24 | %% @doc Upgrades to cowboy_rest.
25 | %% Basically, just returns {upgrade, protocol, cowboy_rest}
26 | %% @see cowboy_rest:init/3
27 | -spec init({atom(), atom()}, cowboy_req:req(), options()) ->
28 | {upgrade, protocol, cowboy_rest}.
29 | init(_Transport, _Req, _Opts) ->
30 | {upgrade, protocol, cowboy_rest}.
31 |
32 | %% @doc Announces the Req and moves on.
33 | %% If verbose := true
in Opts
for this handler
34 | %% prints out a line indicating that endpoint that was hit.
35 | %% @see cowboy_rest:rest_init/2
36 | -spec rest_init(cowboy_req:req(), options()) ->
37 | {ok, cowboy_req:req(), state()}.
38 | rest_init(Req, Opts) ->
39 | Req1 = announce_req(Req, Opts),
40 | #{model := Model} = Opts,
41 | Module = sumo_config:get_prop_value(Model, module),
42 | State = sr_state:new(Opts, Module),
43 | {ok, Req1, State}.
44 |
45 | %% @doc Retrieves the list of allowed methods from Trails metadata.
46 | %% Parses the metadata associated with this path and returns the
47 | %% corresponding list of endpoints.
48 | %% @see cowboy_rest:allowed_methods/2
49 | -spec allowed_methods(cowboy_req:req(), state()) ->
50 | {[binary()], cowboy_req:req(), state()}.
51 | allowed_methods(Req, State) ->
52 | #{path := Path} = sr_state:opts(State),
53 | Trail = trails:retrieve(Path),
54 | Metadata = trails:metadata(Trail),
55 | Methods = [atom_to_method(Method) || Method <- maps:keys(Metadata)],
56 | {Methods, Req, State}.
57 |
58 | %% @doc Returns false
for POST, true
otherwise.
59 | %% @see cowboy_rest:resource_exists/2
60 | -spec resource_exists(cowboy_req:req(), state()) ->
61 | {boolean(), cowboy_req:req(), state()}.
62 | resource_exists(Req, State) ->
63 | {Method, Req1} = cowboy_req:method(Req),
64 | {Method =/= <<"POST">>, Req1, State}.
65 |
66 | %% @doc Always returns "application/json *" with handle_post
.
67 | %% @see cowboy_rest:content_types_accepted/2
68 | %% @todo Use swagger's 'consumes' to auto-generate this if possible
69 | %% Issue
70 | -spec content_types_accepted(cowboy_req:req(), state()) ->
71 | {[{{binary(), binary(), '*'}, atom()}], cowboy_req:req(), state()}.
72 | content_types_accepted(Req, State) ->
73 | #{path := Path} = sr_state:opts(State),
74 | {Method, Req2} = cowboy_req:method(Req),
75 | try
76 | Trail = trails:retrieve(Path),
77 | Metadata = trails:metadata(Trail),
78 | AtomMethod = method_to_atom(Method),
79 | #{AtomMethod := #{consumes := Consumes}} = Metadata,
80 | Handler = compose_handler_name(AtomMethod),
81 | RetList = [{iolist_to_binary(X), Handler} || X <- Consumes],
82 | {RetList, Req2, State}
83 | catch
84 | _:_ -> {[{{<<"application">>, <<"json">>, '*'}, handle_post}], Req, State}
85 | end.
86 |
87 | %% @doc Always returns "application/json" with handle_get
.
88 | %% @see cowboy_rest:content_types_provided/2
89 | %% @todo Use swagger's 'produces' to auto-generate this if possible
90 | %% Issue
91 | -spec content_types_provided(cowboy_req:req(), state()) ->
92 | {[{binary(), atom()}], cowboy_req:req(), state()}.
93 | content_types_provided(Req, State) ->
94 | #{path := Path} = sr_state:opts(State),
95 | {Method, Req2} = cowboy_req:method(Req),
96 | try
97 | Trail = trails:retrieve(Path),
98 | Metadata = trails:metadata(Trail),
99 | AtomMethod = method_to_atom(Method),
100 | #{AtomMethod := #{produces := Produces}} = Metadata,
101 | Handler = compose_handler_name(AtomMethod),
102 | RetList = [{iolist_to_binary(X), Handler} || X <- Produces],
103 | {RetList, Req2, State}
104 | catch
105 | _:_ -> {[{<<"application/json">>, handle_get}], Req, State}
106 | end.
107 |
108 | %% @doc Returns the list of all entities.
109 | %% Fetches the entities from SumoDB using the
110 | %% model
provided in the options.
111 | %% @todo Use query-string as filters.
112 | %% Issue
113 | -spec handle_get(cowboy_req:req(), state()) ->
114 | {iodata(), cowboy_req:req(), state()}.
115 | handle_get(Req, State) ->
116 | #{model := Model} = sr_state:opts(State),
117 | Module = sr_state:module(State),
118 | {Qs, Req1} = cowboy_req:qs_vals(Req),
119 | Conditions = [ {binary_to_atom(Name, unicode),
120 | Value} || {Name, Value} <- Qs ],
121 | Schema = sumo_internal:get_schema(Model),
122 | Fields = [ sumo_internal:field_name(Field) ||
123 | Field <- sumo_internal:schema_fields(Schema) ],
124 | CompareFun = fun({Name, _}) ->
125 | true =:= lists:member(Name, Fields)
126 | end,
127 | ValidConditions = lists:filter(CompareFun, Conditions),
128 | Entities = case ValidConditions of
129 | [] -> sumo:find_all(Model);
130 | _ -> sumo:find_by(Model, Conditions)
131 | end,
132 | Reply = [Module:to_json(Entity) || Entity <- Entities],
133 | JSON = sr_json:encode(Reply),
134 | {JSON, Req1, State}.
135 |
136 | %% @doc Creates a new entity.
137 | %% To parse the body, it uses from_ctx
or
138 | %% from_json/2
from the
139 | %% model
provided in the options.
140 | -spec handle_post(cowboy_req:req(), state()) ->
141 | {{true, binary()} | false | halt, cowboy_req:req(), state()}.
142 | handle_post(Req, State) ->
143 | Module = sr_state:module(State),
144 | try
145 | {SrRequest, Req1} = sr_request:from_cowboy(Req),
146 | Result = case erlang:function_exported(Module, from_ctx, 1) of
147 | false ->
148 | Json = sr_request:body(SrRequest),
149 | Module:from_json(Json);
150 | true ->
151 | Context = #{req => SrRequest, state => State},
152 | Module:from_ctx(Context)
153 | end,
154 | case Result of
155 | {error, Reason} ->
156 | Req2 = cowboy_req:set_resp_body(sr_json:error(Reason), Req1),
157 | {false, Req2, State};
158 | {ok, Entity} ->
159 | handle_post(Entity, Req1, State)
160 | end
161 | catch
162 | _:conflict ->
163 | {ok, Req3} =
164 | cowboy_req:reply(422, [], sr_json:error(<<"Duplicated entity">>), Req),
165 | {halt, Req3, State};
166 | _:badjson ->
167 | Req3 =
168 | cowboy_req:set_resp_body(
169 | sr_json:error(<<"Malformed JSON request">>), Req),
170 | {false, Req3, State}
171 | end.
172 |
173 | %% @doc Persists a new entity.
174 | %% The body must have been parsed beforehand.
175 | -spec handle_post(sumo:user_doc(), cowboy_req:req(), state()) ->
176 | {{true, binary()}, cowboy_req:req(), state()}.
177 | handle_post(Entity, Req1, State) ->
178 | #{model := Model, path := Path} = sr_state:opts(State),
179 | Module = sr_state:module(State),
180 | case erlang:function_exported(Module, duplication_conditions, 1) of
181 | false -> proceed;
182 | true ->
183 | Conditions = Module:duplication_conditions(Entity),
184 | case sumo:find_one(Model, Conditions) of
185 | notfound -> proceed;
186 | Duplicate ->
187 | error_logger:warning_msg( "Duplicated ~p with conditions ~p: ~p"
188 | , [Model, Conditions, Duplicate]),
189 | throw(conflict)
190 | end
191 | end,
192 | PersistedEntity = sumo:persist(Model, Entity),
193 | ResBody = sr_json:encode(Module:to_json(PersistedEntity)),
194 | Req2 = cowboy_req:set_resp_body(ResBody, Req1),
195 | Location = Module:location(PersistedEntity, Path),
196 | {{true, Location}, Req2, State}.
197 |
198 | %% @doc Announces the Req.
199 | %% If verbose := true
in Opts
for this handler
200 | %% prints out a line indicating that endpoint that was hit.
201 | %% @see cowboy_rest:rest_init/2
202 | -spec announce_req(cowboy_req:req(), options()) -> cowboy_req:req().
203 | announce_req(Req, #{verbose := true}) ->
204 | {Method, Req1} = cowboy_req:method(Req),
205 | {Path, Req2} = cowboy_req:path(Req1),
206 | _ = error_logger:info_msg("~s ~s", [Method, Path]),
207 | Req2;
208 | announce_req(Req, _Opts) -> Req.
209 |
210 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
211 | %%% Auxiliary Functions
212 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
213 |
214 | -spec atom_to_method(get|patch|put|post|delete) -> binary().
215 | atom_to_method(get) -> <<"GET">>;
216 | atom_to_method(patch) -> <<"PATCH">>;
217 | atom_to_method(put) -> <<"PUT">>;
218 | atom_to_method(post) -> <<"POST">>;
219 | atom_to_method(delete) -> <<"DELETE">>.
220 |
221 | -spec method_to_atom(binary()) -> atom().
222 | method_to_atom(<<"GET">>) -> get;
223 | method_to_atom(<<"PATCH">>) -> patch;
224 | method_to_atom(<<"PUT">>) -> put;
225 | method_to_atom(<<"POST">>) -> post;
226 | method_to_atom(<<"DELETE">>) -> delete.
227 |
228 | -spec compose_handler_name(get|patch|put|post) -> atom().
229 | compose_handler_name(get) -> handle_get;
230 | compose_handler_name(put) -> handle_put;
231 | compose_handler_name(patch) -> handle_patch;
232 | compose_handler_name(post) -> handle_post.
233 |
--------------------------------------------------------------------------------
/src/sr_json.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Json abstraction library
2 | -module(sr_json).
3 |
4 | -export([encode/1, decode/1]).
5 | -export([encode_date/1, decode_date/1]).
6 | -export([encode_null/1, decode_null/1]).
7 | -export([error/1]).
8 |
9 | -type key() :: binary() | atom().
10 | -type object() :: #{key() => json()}.
11 | -type json() :: object()
12 | | [json()]
13 | | binary()
14 | | number()
15 | | boolean()
16 | | null
17 | .
18 | -type non_null_json() :: object()
19 | | [object()]
20 | | binary()
21 | | number()
22 | | boolean()
23 | .
24 |
25 | -export_type([json/0]).
26 |
27 | %% @doc Internal representation to string
28 | -spec encode(json()) -> iodata().
29 | encode(Json) -> jsx:encode(Json, [uescape]).
30 |
31 | %% @doc String to internal representation
32 | -spec decode(iodata()) -> json().
33 | decode(Data) ->
34 | try jsx:decode(Data, [return_maps])
35 | catch
36 | _:_Error ->
37 | throw(badjson)
38 | end.
39 |
40 | %% @doc Format datetimes as binaries using iso8601
41 | -spec encode_date(calendar:datetime()) -> binary().
42 | encode_date(DateTime) -> iso8601:format(DateTime).
43 |
44 | %% @doc Parse binaries as datetimes using iso8601
45 | %% @todo remove binary_to_list when is8601 specs are fixed
46 | -spec decode_date(binary()) -> calendar:datetime().
47 | decode_date(DateTime) -> iso8601:parse(binary_to_list(DateTime)).
48 |
49 | %% @doc Encode 'undefined' as 'null'.
50 | %% Leave everything else as is.
51 | -spec encode_null(undefined) -> null
52 | ; (json()) -> json().
53 | encode_null(undefined) -> null;
54 | encode_null(Json) -> Json.
55 |
56 | %% @doc Decode 'null' as 'undefined'.
57 | %% Leave everything else as is.
58 | -spec decode_null(null) -> undefined
59 | ; (non_null_json()) -> json().
60 | decode_null(null) -> undefined;
61 | decode_null(Json) -> Json.
62 |
63 | %% @doc Format errors as jsons.
64 | %% Given the error Reason, this function returns the json equivalent to
65 | %% {"error": "Reason"}
.
66 | -spec error(binary()) -> iodata().
67 | error(Error) -> encode(#{error => Error}).
68 |
--------------------------------------------------------------------------------
/src/sr_request.erl:
--------------------------------------------------------------------------------
1 | -module(sr_request).
2 |
3 | -export([ from_cowboy/1
4 | , body/1
5 | , headers/1
6 | , path/1
7 | , bindings/1
8 | ]).
9 |
10 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
11 | %%% Types
12 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
13 |
14 | -type binding_name() :: id | atom().
15 |
16 | -type http_header_name_lowercase() :: binary().
17 | -type http_header() :: {http_header_name_lowercase(), iodata()}.
18 |
19 | -opaque req() ::
20 | #{ body := sr_json:json()
21 | , headers := [http_header()]
22 | , path := binary()
23 | , bindings := #{binding_name() => any()}
24 | }.
25 |
26 | -export_type [req/0].
27 |
28 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
29 | %%% Constructor, getters/setters
30 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
31 |
32 | -spec from_cowboy(cowboy_req:req()) -> {req(), cowboy_req:req()}.
33 | from_cowboy(CowboyReq) ->
34 | {ok, RawBody, CowboyReq1} = cowboy_req:body(CowboyReq),
35 | Body = sr_json:decode(RawBody),
36 | {Headers, CowboyReq2} = cowboy_req:headers(CowboyReq1),
37 | {Path, CowboyReq3} = cowboy_req:path(CowboyReq2),
38 | {BindingsList, CowboyReq4} = cowboy_req:bindings(CowboyReq3),
39 | Request = #{ body => Body
40 | , headers => Headers
41 | , path => Path
42 | , bindings => maps:from_list(BindingsList)
43 | },
44 | {Request, CowboyReq4}.
45 |
46 | -spec body(req()) -> sr_json:json() | undefined.
47 | body(#{body := Body}) ->
48 | Body.
49 |
50 | -spec headers(req()) -> [http_header()].
51 | headers(#{headers := Headers}) ->
52 | Headers.
53 |
54 | -spec path(req()) -> binary().
55 | path(#{path := Path}) ->
56 | Path.
57 |
58 | -spec bindings(req()) -> #{atom() => any()}.
59 | bindings(#{bindings := Bindings}) ->
60 | Bindings.
61 |
--------------------------------------------------------------------------------
/src/sr_single_entity_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Base GET|PUT|DELETE /[entity]s/:id implementation
2 | -module(sr_single_entity_handler).
3 |
4 | -include_lib("mixer/include/mixer.hrl").
5 | -mixin([{ sr_entities_handler
6 | , [ init/3
7 | , allowed_methods/2
8 | , content_types_provided/2
9 | , content_types_accepted/2
10 | , announce_req/2
11 | ]
12 | }]).
13 |
14 | -export([ rest_init/2
15 | , resource_exists/2
16 | , handle_get/2
17 | , handle_put/2
18 | , handle_patch/2
19 | , delete_resource/2
20 | , id_from_binding_internal/2 % exported only for test coverage
21 | ]).
22 |
23 | -type options() :: sr_state:options().
24 | -type state() :: sr_state:state().
25 | -export_type([state/0, options/0]).
26 |
27 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
28 | %%% Cowboy Callbacks
29 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
30 | %% @doc Announces the Req and moves on.
31 | %% It extracts the :id
binding from the Req and leaves it in
32 | %% the id
key in the state.
33 | %% @see cowboy_rest:rest_init/2
34 | -spec rest_init(cowboy_req:req(), options()) ->
35 | {ok, cowboy_req:req(), state()}.
36 | rest_init(Req, Opts) ->
37 | Req1 = announce_req(Req, Opts),
38 | #{model := Model} = Opts,
39 | Module = sumo_config:get_prop_value(Model, module),
40 | {Id, Req2} = cowboy_req:binding(id, Req1),
41 | ActualId = id_from_binding(Id, Model, Module),
42 | State = sr_state:new(Opts, Module),
43 | State1 = sr_state:id(State, ActualId),
44 | {ok, Req2, State1}.
45 |
46 | %% @doc Verifies if there is an entity with the given id
.
47 | %% The provided id must be the value for the id field in
48 | %% SumoDb. If the entity is found, it's kept in the
49 | %% state.
50 | %% @see cowboy_rest:resource_exists/2
51 | %% @see sumo:find/2
52 | -spec resource_exists(cowboy_req:req(), state()) ->
53 | {boolean(), cowboy_req:req(), state()}.
54 | resource_exists(Req, State) ->
55 | Id = sr_state:id(State),
56 | #{model := Model} = sr_state:opts(State),
57 | case sumo:fetch(Model, Id) of
58 | notfound -> {false, Req, State};
59 | Entity -> {true, Req, sr_state:entity(State, Entity)}
60 | end.
61 |
62 | %% @doc Renders the found entity.
63 | %% @see resource_exists/2
64 | -spec handle_get(cowboy_req:req(), state()) ->
65 | {iodata(), cowboy_req:req(), state()}.
66 | handle_get(Req, State) ->
67 | Entity = sr_state:entity(State),
68 | Module = sr_state:module(State),
69 | ResBody = sr_json:encode(Module:to_json(Entity)),
70 | {ResBody, Req, State}.
71 |
72 | %% @doc Updates the found entity.
73 | %% To parse the body, it uses update/2
from the
74 | %% model
provided in the options.
75 | %% @see resource_exists/2
76 | -spec handle_patch(cowboy_req:req(), state()) ->
77 | {boolean() | halt, cowboy_req:req(), state()}.
78 | handle_patch(Req, State) ->
79 | Entity = sr_state:entity(State),
80 | Module = sr_state:module(State),
81 | try
82 | {ok, Body, Req1} = cowboy_req:body(Req),
83 | Json = sr_json:decode(Body),
84 | persist(Module:update(Entity, Json), Req1, State)
85 | catch
86 | _:badjson ->
87 | Req3 =
88 | cowboy_req:set_resp_body(
89 | sr_json:error(<<"Malformed JSON request">>), Req),
90 | {false, Req3, State}
91 | end.
92 |
93 | %% @doc Updates the entity if found, otherwise it creates a new one.
94 | %% To parse the body, it uses either update/2
or
95 | %% from_json/2
(if defined) or from_json/1
96 | %% from the model
provided in the options.
97 | %% @see resource_exists/2
98 | -spec handle_put(cowboy_req:req(), state()) ->
99 | {boolean() | halt, cowboy_req:req(), state()}.
100 | handle_put(Req, State) ->
101 | try
102 | Module = sr_state:module(State),
103 | {SrRequest, Req1} = sr_request:from_cowboy(Req),
104 | Entity = case sr_state:entity(State) of
105 | undefined ->
106 | Context = #{req => SrRequest, state => State},
107 | build_entity(Context);
108 | OldEntity ->
109 | Json = sr_request:body(SrRequest),
110 | Module:update(OldEntity, Json)
111 | end,
112 | persist(Entity, Req1, State)
113 | catch
114 | _:badjson ->
115 | Req2 =
116 | cowboy_req:set_resp_body(
117 | sr_json:error(<<"Malformed JSON request">>), Req),
118 | {false, Req2, State}
119 | end.
120 |
121 | %% @doc Deletes the found entity.
122 | %% @see resource_exists/2
123 | -spec delete_resource(cowboy_req:req(), state()) ->
124 | {boolean() | halt, cowboy_req:req(), state()}.
125 | delete_resource(Req, State) ->
126 | Id = sr_state:id(State),
127 | #{model := Model} = sr_state:opts(State),
128 | Result = sumo:delete(Model, Id),
129 | {Result, Req, State}.
130 |
131 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
132 | %%% Auxiliary Functions
133 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
134 |
135 | build_entity(#{req := SrRequest, state := State} = Context) ->
136 | Module = sr_state:module(State),
137 | case erlang:function_exported(Module, from_ctx, 1) of
138 | false ->
139 | Id = sr_state:id(State),
140 | Json = sr_request:body(SrRequest),
141 | from_json(Module, Id, Json);
142 | true ->
143 | Module:from_ctx(Context)
144 | end.
145 |
146 | from_json(Module, Id, Json) ->
147 | try Module:from_json(Id, Json)
148 | catch
149 | _:undef -> Module:from_json(Json)
150 | end.
151 |
152 | persist({error, Reason}, Req, State) ->
153 | Req1 = cowboy_req:set_resp_body(sr_json:error(Reason), Req),
154 | {false, Req1, State};
155 | persist({ok, Entity}, Req1, State) ->
156 | Module = sr_state:module(State),
157 | #{model := Model} = sr_state:opts(State),
158 | PersistedEntity = sumo:persist(Model, Entity),
159 | ResBody = sr_json:encode(Module:to_json(PersistedEntity)),
160 | Req2 = cowboy_req:set_resp_body(ResBody, Req1),
161 | {true, Req2, State}.
162 |
163 | -spec id_from_binding(binary(), atom(), atom()) -> term().
164 | id_from_binding(Id, Model, Module) ->
165 | case erlang:function_exported(Module, id_from_binding, 1) of
166 | false -> id_from_binding_internal(Id, sumo_internal:id_field_type(Model));
167 | true -> Module:id_from_binding(Id)
168 | end.
169 |
170 | -spec id_from_binding_internal(binary(), binary | string | integer) -> term().
171 | id_from_binding_internal(Id, binary) ->
172 | Id;
173 | id_from_binding_internal(Id, string) ->
174 | binary_to_list(Id);
175 | id_from_binding_internal(BinaryId, integer) ->
176 | try binary_to_integer(BinaryId) of
177 | Id -> Id
178 | catch
179 | error:badarg -> -1
180 | end.
181 |
--------------------------------------------------------------------------------
/src/sr_state.erl:
--------------------------------------------------------------------------------
1 | -module(sr_state).
2 |
3 | %% constructor, getters and setters
4 | -export([ new/2
5 | , id/1
6 | , id/2
7 | , entity/1
8 | , entity/2
9 | , module/1
10 | , opts/1
11 | , path/1
12 | ]).
13 |
14 | %% functions to work with user opts
15 | -export([ set/3
16 | , retrieve/3
17 | , remove/2
18 | ]).
19 |
20 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
21 | %%% Types
22 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
23 |
24 | -type options() ::
25 | #{ path := string()
26 | , model := atom()
27 | , verbose => boolean()
28 | }.
29 |
30 | -opaque state() ::
31 | #{ opts := options()
32 | , id => binary()
33 | , entity => sumo:user_doc()
34 | , module := module()
35 | , user_opts := map()
36 | }.
37 |
38 | -export_type([state/0]).
39 |
40 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
41 | %%% Constructor, Getters/Setters
42 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
43 |
44 | -spec new(options(), module()) -> state().
45 | new(Opts, Module) ->
46 | #{opts => Opts, module => Module, user_opts => #{}}.
47 |
48 | -spec id(state()) -> binary() | undefined.
49 | id(#{id := Id}) ->
50 | Id;
51 | id(_State) ->
52 | undefined.
53 |
54 | -spec id(state(), binary()) -> state().
55 | id(State, Id) ->
56 | State#{id => Id}.
57 |
58 | -spec entity(state()) -> sumo:user_doc() | undefined.
59 | entity(#{entity := Entity}) ->
60 | Entity;
61 | entity(_State) ->
62 | undefined.
63 |
64 | -spec entity(state(), sumo:user_doc()) -> state().
65 | entity(State, Entity) ->
66 | State#{entity => Entity}.
67 |
68 | -spec module(state()) -> module().
69 | module(#{module := Module}) ->
70 | Module.
71 |
72 | -spec opts(state()) -> options().
73 | opts(#{opts := Opts}) ->
74 | Opts.
75 |
76 | -spec path(state()) -> string().
77 | path(#{opts := #{path := Path}}) ->
78 | Path.
79 |
80 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
81 | %%% User opts Functions
82 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
83 |
84 | -spec set(Key :: term(), Value :: term(), state()) -> state().
85 | set(Key, Value, #{user_opts := UserOpts} = State) ->
86 | NewUserOpts = UserOpts#{Key => Value},
87 | State#{user_opts => NewUserOpts}.
88 |
89 | -spec retrieve(Key :: term(), state(), Default :: term()) -> term().
90 | retrieve(Key, #{user_opts := UserOpts}, Default) ->
91 | maps:get(Key, UserOpts, Default).
92 |
93 | -spec remove(Key :: term(), state()) -> state().
94 | remove(Key, #{user_opts := UserOpts} = State) ->
95 | NewUserOpts = maps:remove(Key, UserOpts),
96 | State#{user_opts => NewUserOpts}.
97 |
--------------------------------------------------------------------------------
/src/sumo_rest.app.src:
--------------------------------------------------------------------------------
1 | {application, sumo_rest, [
2 | {description, "Generic cowboy handlers to work with Sumo"},
3 | {vsn, "0.3.4"},
4 | {id, "sumo_rest"},
5 | {registered, []},
6 | {applications,
7 | [ kernel
8 | , stdlib
9 | , crypto
10 | , inets
11 | , cowboy
12 | , trails
13 | , cowboy_swagger
14 | , jsx
15 | , sumo_db
16 | , iso8601
17 | ]},
18 | {modules, []},
19 | {env, []},
20 | {maintainers,["Inaka"]},
21 | {licenses,["Apache 2.0"]},
22 | {links,[ {"Github", "https://github.com/inaka/sumo_rest"},
23 | {"Example", "https://github.com/inaka/canillita"}
24 | ]},
25 | {build_tools,["rebar3"]}
26 | ]}.
27 |
--------------------------------------------------------------------------------
/src/sumo_rest_doc.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Implement this behavior on your entities so the handlers can
2 | %%% properly [un]marshall them.
3 | -module(sumo_rest_doc).
4 |
5 | -type json() :: sr_json:json().
6 |
7 | -type entity() :: sumo:user_doc().
8 | -export_type([entity/0]).
9 |
10 | -type path() :: string().
11 | -export_type([path/0]).
12 |
13 | -type reason() :: iodata().
14 | -export_type([reason/0]).
15 |
16 | -type duplication_conditions() :: sumo:conditions().
17 | -export_type([duplication_conditions/0]).
18 |
19 | -type context() :: #{req := sr_request:req(), state := sr_state:state()}.
20 | -export_type([context/0]).
21 |
22 | -callback to_json(entity()) -> json().
23 | -callback from_json(json()) -> {ok, entity()} | {error, reason()}.
24 | -callback from_ctx(context()) -> {ok, entity()} | {error, reason()}.
25 | -callback update(entity(), json()) -> {ok, entity()} | {error, reason()}.
26 | -callback location(entity(), path()) -> binary().
27 | %% it's only needed if dups should raise 422 conflict
28 | -callback duplication_conditions(entity()) -> duplication_conditions().
29 | %% it's only needed if ids are not coming in PUT jsons
30 | -callback from_json(binary(), json()) -> {ok, entity()} | {error, reason()}.
31 | %% it's only needed if ids are different from binary, integer or string
32 | -callback id_from_binding(binary()) -> term().
33 |
34 | -optional_callbacks([ duplication_conditions/1
35 | , from_json/1
36 | , from_json/2
37 | , id_from_binding/1
38 | , from_ctx/1
39 | ]).
40 |
--------------------------------------------------------------------------------
/test/cover.spec:
--------------------------------------------------------------------------------
1 | %% Specific modules to include in cover.
2 | {
3 | incl_mods,
4 | [ sr_entities_handler
5 | , sr_single_entity_handler
6 | , sr_json
7 | , sumo_rest_doc
8 | ]
9 | }.
10 |
--------------------------------------------------------------------------------
/test/sr_echo_request_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(sr_echo_request_SUITE).
2 |
3 | -include_lib("mixer/include/mixer.hrl").
4 |
5 | -mixin([{ sr_test_utils
6 | , [ init_per_suite/1
7 | , end_per_suite/1
8 | ]
9 | }]).
10 |
11 | -export([ all/0
12 | , init_per_testcase/2
13 | , end_per_testcase/2
14 | ]).
15 | -export([ success_scenario/1
16 | ]).
17 |
18 | -spec all() -> [atom()].
19 | all() -> sr_test_utils:all(?MODULE).
20 |
21 | -spec init_per_testcase(atom(), sr_test_utils:config()) ->
22 | sr_test_utils:config().
23 | init_per_testcase(_, Config) ->
24 | _ = sumo:delete_all(echo_request),
25 | Config.
26 |
27 | -spec end_per_testcase(atom(), sr_test_utils:config()) ->
28 | sr_test_utils:config().
29 | end_per_testcase(_, Config) ->
30 | Config.
31 |
32 | -spec success_scenario(sr_test_utils:config()) -> {comment, string()}.
33 | success_scenario(_Config) ->
34 | ct:comment("Request an Echo Request"),
35 | Headers = #{<<"content-type">> => <<"application/json">>},
36 | #{status_code := 201} =
37 | sr_test_utils:api_call(put, "/echo/1", Headers, #{<<"hi">> => <<"there">>}),
38 |
39 | {comment, ""}.
40 |
--------------------------------------------------------------------------------
/test/sr_elements_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(sr_elements_SUITE).
2 |
3 | -include_lib("mixer/include/mixer.hrl").
4 |
5 | -mixin([{ sr_test_utils
6 | , [ init_per_suite/1
7 | , end_per_suite/1
8 | ]
9 | }]).
10 |
11 | -export([ all/0
12 | , init_per_testcase/2
13 | , end_per_testcase/2
14 | ]).
15 | -export([ success_scenario/1
16 | , duplicated_key/1
17 | , invalid_headers/1
18 | , invalid_parameters/1
19 | , not_found/1
20 | , location/1
21 | , binary_id_conversion/1
22 | ]).
23 |
24 | -spec all() -> [atom()].
25 | all() -> sr_test_utils:all(?MODULE).
26 |
27 | -spec init_per_testcase(atom(), sr_test_utils:config()) ->
28 | sr_test_utils:config().
29 | init_per_testcase(_, Config) ->
30 | _ = sumo:delete_all(elements),
31 | Config.
32 |
33 | -spec end_per_testcase(atom(), sr_test_utils:config()) ->
34 | sr_test_utils:config().
35 | end_per_testcase(_, Config) ->
36 | Config.
37 |
38 | -spec success_scenario(sr_test_utils:config()) -> {comment, string()}.
39 | success_scenario(_Config) ->
40 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>},
41 |
42 | ct:comment("There are no elements"),
43 | #{status_code := 200, body := Body0} =
44 | sr_test_utils:api_call(get, "/elements"),
45 | [] = sr_json:decode(Body0),
46 |
47 | ct:comment("Element 1 is created"),
48 | #{status_code := 201, body := Body1} =
49 | sr_test_utils:api_call(
50 | post, "/elements", Headers,
51 | #{ key => 1
52 | , value => <<"val1">>
53 | }),
54 | #{ <<"key">> := 1
55 | , <<"created_at">> := CreatedAt
56 | , <<"updated_at">> := CreatedAt
57 | } = Element1 = sr_json:decode(Body1),
58 |
59 | ct:comment("Find element using query string"),
60 | #{status_code := 200, body := BodyA} =
61 | sr_test_utils:api_call(get, "/elements?value=val1"),
62 | [#{ <<"created_at">> := CreatedAt
63 | , <<"key">> := 1
64 | , <<"updated_at">> := CreatedAt
65 | , <<"value">> := <<"val1">>
66 | }] = sr_json:decode(BodyA),
67 |
68 | ct:comment("Find elements non existent value using query string"),
69 | #{status_code := 200, body := BodyB} =
70 | sr_test_utils:api_call(get, "/elements?novalue=noval"),
71 | [Element1] = sr_json:decode(BodyB),
72 |
73 | ct:comment("Element 1 is modified"),
74 | #{status_code := 422, body := Body01} =
75 | sr_test_utils:api_call(
76 | put, "/elements", #{<<"content-type">> => <<"application/json">>},
77 | #{ key => 1
78 | , value => <<"val1">>
79 | }),
80 | #{ <<"error">> := <<"Duplicated entity">>
81 | } = sr_json:decode(Body01),
82 |
83 | ct:comment("There is one element now"),
84 | #{status_code := 200, body := Body2} =
85 | sr_test_utils:api_call(get, "/elements"),
86 | [Element1] = sr_json:decode(Body2),
87 |
88 | ct:comment("And we can fetch it"),
89 | #{status_code := 200, body := Body21} =
90 | sr_test_utils:api_call(get, "/elements/1"),
91 | Element1 = sr_json:decode(Body21),
92 |
93 | ct:comment("The element value can be changed"),
94 | #{status_code := 200, body := Body3} =
95 | sr_test_utils:api_call(
96 | put, "/elements/1", Headers,
97 | #{ key => 1
98 | , value => <<"newval3">>
99 | }),
100 | #{ <<"key">> := 1
101 | , <<"value">> := <<"newval3">>
102 | , <<"created_at">> := CreatedAt
103 | , <<"updated_at">> := UpdatedAt
104 | } = Element3 = sr_json:decode(Body3),
105 | true = UpdatedAt >= CreatedAt,
106 |
107 | ct:comment("Still just one element"),
108 | #{status_code := 200, body := Body4} =
109 | sr_test_utils:api_call(get, "/elements"),
110 | [Element3] = sr_json:decode(Body4),
111 |
112 | ct:comment("The element value can be changed by PATCH"),
113 | #{status_code := 200, body := Body5} =
114 | sr_test_utils:api_call(
115 | patch, "/elements/1", Headers, #{value => <<"newval5">>}),
116 | #{ <<"key">> := 1
117 | , <<"value">> := <<"newval5">>
118 | , <<"created_at">> := CreatedAt
119 | , <<"updated_at">> := UpdatedAt5
120 | } = Element5 = sr_json:decode(Body5),
121 | true = UpdatedAt5 >= CreatedAt,
122 |
123 | ct:comment("Still just one element"),
124 | #{status_code := 200, body := Body6} =
125 | sr_test_utils:api_call(get, "/elements"),
126 | [Element5] = sr_json:decode(Body6),
127 |
128 | ct:comment("Elements can be created by PUT"),
129 | #{status_code := 201, body := Body7} =
130 | sr_test_utils:api_call(
131 | put, "/elements/2", Headers,
132 | #{ key => 2
133 | , value => <<"val2">>
134 | }),
135 | #{ <<"key">> := 2
136 | , <<"value">> := <<"val2">>
137 | , <<"created_at">> := CreatedAt7
138 | , <<"updated_at">> := CreatedAt7
139 | } = Element7 = sr_json:decode(Body7),
140 | true = CreatedAt7 >= CreatedAt,
141 |
142 | ct:comment("There are two elements now"),
143 | #{status_code := 200, body := Body8} =
144 | sr_test_utils:api_call(get, "/elements"),
145 | [Element7] = sr_json:decode(Body8) -- [Element5],
146 |
147 | ct:comment("Element1 is deleted"),
148 | #{status_code := 204} = sr_test_utils:api_call(delete, "/elements/1"),
149 |
150 | ct:comment("One element again"),
151 | #{status_code := 200, body := Body9} =
152 | sr_test_utils:api_call(get, "/elements"),
153 | [Element7] = sr_json:decode(Body9),
154 |
155 | ct:comment("DELETE is not idempotent"),
156 | #{status_code := 204} = sr_test_utils:api_call(delete, "/elements/2"),
157 | #{status_code := 404} = sr_test_utils:api_call(delete, "/elements/2"),
158 |
159 | ct:comment("There are no elements"),
160 | #{status_code := 200, body := Body10} =
161 | sr_test_utils:api_call(get, "/elements"),
162 | [] = sr_json:decode(Body10),
163 |
164 | {comment, ""}.
165 |
166 | -spec duplicated_key(sr_test_utils:config()) -> {comment, string()}.
167 | duplicated_key(_Config) ->
168 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>},
169 | Body =
170 | #{ key => <<"element1">>
171 | , value => <<"val1">>
172 | },
173 |
174 | ct:comment("Element 1 is created"),
175 | #{status_code := 201} =
176 | sr_test_utils:api_call(post, "/elements", Headers, Body),
177 |
178 | ct:comment("Element 1 can't be created again"),
179 | #{status_code := 422} =
180 | sr_test_utils:api_call(post, "/elements", Headers, Body),
181 |
182 | {comment, ""}.
183 |
184 | -spec invalid_headers(sr_test_utils:config()) -> {comment, string()}.
185 | invalid_headers(_Config) ->
186 | NoHeaders = #{},
187 | InvalidHeaders = #{<<"content-type">> => <<"text/plain">>},
188 | InvalidAccept = #{ <<"content-type">> => <<"application/json">>
189 | , <<"accept">> => <<"text/html">>
190 | },
191 |
192 | ct:comment("content-type must be provided for POST and PUT"),
193 | #{status_code := 415} =
194 | sr_test_utils:api_call(post, "/elements", NoHeaders, <<>>),
195 | #{status_code := 415} =
196 | sr_test_utils:api_call(put, "/elements/noheaders", NoHeaders, <<>>),
197 |
198 | ct:comment("content-type must be JSON for POST and PUT"),
199 | #{status_code := 415} =
200 | sr_test_utils:api_call(post, "/elements", InvalidHeaders, <<>>),
201 | #{status_code := 415} =
202 | sr_test_utils:api_call(put, "/elements/badtype", InvalidHeaders, <<>>),
203 |
204 | ct:comment("Agent must accept json for POST, GET and PUT"),
205 | #{status_code := 406} =
206 | sr_test_utils:api_call(post, "/elements", InvalidAccept, <<>>),
207 | #{status_code := 406} =
208 | sr_test_utils:api_call(get, "/elements", InvalidAccept, <<>>),
209 | #{status_code := 406} =
210 | sr_test_utils:api_call(put, "/elements/badaccept", InvalidAccept, <<>>),
211 | #{status_code := 406} =
212 | sr_test_utils:api_call(get, "/elements/badaccept", InvalidAccept, <<>>),
213 |
214 | {comment, ""}.
215 |
216 | -spec invalid_parameters(sr_test_utils:config()) -> {comment, string()}.
217 | invalid_parameters(_Config) ->
218 | Headers = #{<<"content-type">> => <<"application/json">>},
219 | _ = sumo:persist(elements, sr_elements:new(1, <<"val">>)),
220 |
221 | ct:comment("Empty or broken parameters are reported"),
222 | #{status_code := 400} =
223 | sr_test_utils:api_call(post, "/elements", Headers, <<>>),
224 | #{status_code := 400} =
225 | sr_test_utils:api_call(put, "/elements/nobody", Headers, <<>>),
226 | #{status_code := 400} =
227 | sr_test_utils:api_call(put, "/elements/1", Headers, <<>>),
228 | #{status_code := 400} =
229 | sr_test_utils:api_call(patch, "/elements/1", Headers, <<>>),
230 | #{status_code := 400} =
231 | sr_test_utils:api_call(post, "/elements", Headers, <<"{">>),
232 | #{status_code := 400} =
233 | sr_test_utils:api_call(put, "/elements/broken", Headers, <<"{">>),
234 | #{status_code := 400} =
235 | sr_test_utils:api_call(put, "/elements/1", Headers, <<"{">>),
236 | #{status_code := 400} =
237 | sr_test_utils:api_call(patch, "/elements/1", Headers, <<"{">>),
238 |
239 | ct:comment("Missing parameters are reported"),
240 | None = #{},
241 | #{status_code := 400} =
242 | sr_test_utils:api_call(post, "/elements", Headers, None),
243 | #{status_code := 400} =
244 | sr_test_utils:api_call(put, "/elements/none", Headers, None),
245 | #{status_code := 400} =
246 | sr_test_utils:api_call(put, "/elements/1", Headers, None),
247 | #{status_code := 400} =
248 | sr_test_utils:api_call(patch, "/elements/1", Headers, None),
249 |
250 | NoVal = #{key => <<"noval">>},
251 | #{status_code := 400} =
252 | sr_test_utils:api_call(post, "/elements", Headers, NoVal),
253 | #{status_code := 400} =
254 | sr_test_utils:api_call(put, "/elements/noval", Headers, NoVal),
255 | #{status_code := 400} =
256 | sr_test_utils:api_call(put, "/elements/1", Headers, NoVal),
257 | #{status_code := 400} =
258 | sr_test_utils:api_call(patch, "/elements/1", Headers, NoVal),
259 |
260 | {comment, ""}.
261 |
262 | -spec not_found(sr_test_utils:config()) -> {comment, string()}.
263 | not_found(_Config) ->
264 | ct:comment("Not existing element is not found"),
265 | #{status_code := 404} = sr_test_utils:api_call(get, "/elements/notfound"),
266 | #{status_code := 404} = sr_test_utils:api_call(patch, "/elements/notfound"),
267 | #{status_code := 404} = sr_test_utils:api_call(delete, "/elements/notfound"),
268 | {comment, ""}.
269 |
270 | -spec binary_id_conversion(sr_test_utils:config()) -> {comment, string()}.
271 | binary_id_conversion(_Config) ->
272 | ct:comment("Different types of ids"),
273 | 1 = sr_single_entity_handler:id_from_binding_internal(<<"1">>, integer),
274 | -1 = sr_single_entity_handler:id_from_binding_internal(<<"one">>, integer),
275 | <<"binary">> =
276 | sr_single_entity_handler:id_from_binding_internal(<<"binary">>, binary),
277 | "string" =
278 | sr_single_entity_handler:id_from_binding_internal(<<"string">>, string),
279 | {comment, ""}.
280 |
281 | -spec location(sr_test_utils:config()) -> {comment, string()}.
282 | location(_Config) ->
283 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>},
284 |
285 | ct:comment("Element 1 is created"),
286 | Key = <<"element1">>,
287 | #{status_code := 201, headers := ResponseHeaders} =
288 | sr_test_utils:api_call(
289 | post, "/elements", Headers,
290 | #{ key => <<"element1">>
291 | , value => <<"val1">>
292 | }),
293 | ct:comment("and its location header is set correctly"),
294 | Location = proplists:get_value(<<"location">>, ResponseHeaders),
295 | Location = iolist_to_binary(["/elements/", Key]),
296 |
297 | {comment, ""}.
298 |
--------------------------------------------------------------------------------
/test/sr_json_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(sr_json_SUITE).
2 |
3 | -export([all/0]).
4 | -export([ jsons/1
5 | , dates/1
6 | , nulls/1
7 | , types/1
8 | ]).
9 |
10 | -spec all() -> [atom()].
11 | all() -> sr_test_utils:all(?MODULE).
12 |
13 | -spec jsons(sr_test_utils:config()) -> {comment, string()}.
14 | jsons(_Config) ->
15 | WorksWith =
16 | fun(Json) ->
17 | ct:comment("Works with ~s", [Json]),
18 | Json = sr_json:encode(sr_json:decode(Json))
19 | end,
20 | Jsons =
21 | [ <<"null">>
22 | , <<"true">>
23 | , <<"false">>
24 | , <<"1">>
25 | , <<"0.3">>
26 | , <<"\"a string\"">>
27 | , <<"[true,true,false]">>
28 | , <<"{\"a\":\"b\",\"c\":[1,2,3]}">>
29 | ],
30 | lists:foreach(WorksWith, Jsons),
31 |
32 | ct:comment("Properly fails to decode {"),
33 | try sr_json:decode(<<"{">>) of
34 | R -> ct:fail("Unexpected result: ~p", [R])
35 | catch
36 | throw:badjson -> ok
37 | end,
38 |
39 | {comment, ""}.
40 |
41 | -spec dates(sr_test_utils:config()) -> {comment, string()}.
42 | dates(_Config) ->
43 | Now = calendar:universal_time(),
44 | ct:comment("Encodes ~p", [Now]),
45 |
46 | Now = sr_json:decode_date(sr_json:encode_date(Now)),
47 |
48 | {comment, ""}.
49 |
50 | -spec nulls(sr_test_utils:config()) -> {comment, string()}.
51 | nulls(_Config) ->
52 | ct:comment("Encodes undefined as null"),
53 | null = sr_json:encode_null(undefined),
54 |
55 | ct:comment("Leaves the rest untouched"),
56 | #{} = sr_json:encode_null(#{}),
57 |
58 | ct:comment("Decodes null as undefined"),
59 | undefined = sr_json:decode_null(null),
60 |
61 | ct:comment("Leaves the rest untouched"),
62 | #{} = sr_json:decode_null(#{}),
63 |
64 | {comment, ""}.
65 |
66 | -spec types(sr_test_utils:config()) -> {comment, string()}.
67 | types(_Config) ->
68 | Nested = #{a => #{b => [1, #{c => [true, 1.2, <<"binary">>]}]}},
69 | JsonTypes = [ [<<"binary">>, <<"binary">>, <<"binary">>]
70 | , [1, 2, 3, 4, 5]
71 | , [#{a => 1}, #{b => 2}, #{c => Nested}]
72 | , Nested
73 | , [<<"mixed">>, <<"list">>, Nested, 3, #{a => []}, a, true, 3.2]
74 | ],
75 | _ = [sr_json:encode(Json) || Json <- JsonTypes],
76 |
77 | {comment, ""}.
78 |
--------------------------------------------------------------------------------
/test/sr_meta_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(sr_meta_SUITE).
2 |
3 | -include_lib("mixer/include/mixer.hrl").
4 | -mixin([{ ktn_meta_SUITE
5 | , [ all/0
6 | , dialyzer/1
7 | , xref/1
8 | , elvis/1
9 | ]
10 | }]).
11 |
12 | -export([init_per_suite/1, end_per_suite/1]).
13 |
14 | init_per_suite(Config) -> [{application, sumo_rest} | Config].
15 | end_per_suite(Config) -> Config.
16 |
--------------------------------------------------------------------------------
/test/sr_sessions_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(sr_sessions_SUITE).
2 |
3 | -include_lib("mixer/include/mixer.hrl").
4 |
5 | -mixin([{ sr_test_utils
6 | , [ init_per_suite/1
7 | , end_per_suite/1
8 | ]
9 | }]).
10 |
11 | -export([ all/0
12 | , init_per_testcase/2
13 | , end_per_testcase/2
14 | ]).
15 | -export([ success_scenario/1
16 | , invalid_auth/1
17 | , invalid_headers/1
18 | , invalid_parameters/1
19 | , conflict/1
20 | , location/1
21 | ]).
22 |
23 | -spec all() -> [atom()].
24 | all() -> sr_test_utils:all(?MODULE).
25 |
26 | -spec init_per_testcase(atom(), sr_test_utils:config()) ->
27 | sr_test_utils:config().
28 | init_per_testcase(_, Config) ->
29 | _ = sumo:delete_all(sessions),
30 | {U, P} = first_user(),
31 | [{basic_auth, {binary_to_list(U), binary_to_list(P)}} | Config].
32 |
33 | -spec end_per_testcase(atom(), sr_test_utils:config()) ->
34 | sr_test_utils:config().
35 | end_per_testcase(_, Config) ->
36 | Config.
37 |
38 | -spec success_scenario(sr_test_utils:config()) -> {comment, string()}.
39 | success_scenario(Config) ->
40 | {basic_auth, BasicAuth} = lists:keyfind(basic_auth, 1, Config),
41 | Headers = #{ basic_auth => BasicAuth
42 | , <<"content-type">> => <<"application/json">>
43 | },
44 |
45 | ct:comment("There are no sessions"),
46 | [] = sumo:find_all(sessions),
47 |
48 | ct:comment("A session is created"),
49 | #{status_code := 201, body := Body1} =
50 | sr_test_utils:api_call(post, "/sessions", Headers, #{agent => <<"a1">>}),
51 | #{ <<"id">> := Session1Id
52 | , <<"token">> := Token1
53 | , <<"agent">> := <<"a1">>
54 | , <<"created_at">> := CreatedAt1
55 | , <<"expires_at">> := ExpiresAt1
56 | } = sr_json:decode(Body1),
57 | true = ExpiresAt1 >= CreatedAt1,
58 |
59 | ct:comment("Session ~s is there", [Session1Id]),
60 | [Session1] = sumo:find_all(sessions),
61 | Token1 = sr_sessions:token(Session1),
62 |
63 | ct:comment("The session agent can be changed"),
64 | #{status_code := 200, body := Body2} =
65 | sr_test_utils:api_call(
66 | put, "/sessions/" ++ Session1Id, Headers,
67 | #{agent => <<"a2">>}),
68 | #{ <<"id">> := Session1Id
69 | , <<"agent">> := <<"a2">>
70 | , <<"created_at">> := CreatedAt1
71 | , <<"expires_at">> := ExpiresAt2
72 | } = sr_json:decode(Body2),
73 | true = ExpiresAt2 >= ExpiresAt1,
74 |
75 | ct:comment("Still just one session"),
76 | [Session2] = sumo:find_all(sessions),
77 |
78 | ct:comment("Another session can be created with no agent"),
79 | #{status_code := 201, body := Body3} =
80 | sr_test_utils:api_call(post, "/sessions", Headers, #{}),
81 | #{ <<"id">> := Session3Id
82 | , <<"token">> := Token3
83 | , <<"agent">> := null
84 | , <<"created_at">> := CreatedAt3
85 | , <<"expires_at">> := ExpiresAt3
86 | } = sr_json:decode(Body3),
87 | true = ExpiresAt3 >= CreatedAt3,
88 | ct:log("~p < ~p ?", [ExpiresAt3, ExpiresAt2]),
89 | true = ExpiresAt3 >= ExpiresAt2,
90 | true = CreatedAt3 >= CreatedAt1,
91 | case Session3Id of
92 | Session1Id -> ct:fail("Duplicated Session Id: ~p", [Session1Id]);
93 | Session3Id -> ok
94 | end,
95 | case Token3 of
96 | Token1 -> ct:fail("Duplicated token: ~p", [Token1]);
97 | Token3 -> ok
98 | end,
99 |
100 | ct:comment("There are 2 sessions"),
101 | [Session3] = sumo:find_all(sessions) -- [Session2],
102 |
103 | ct:comment("Session2 is deleted"),
104 | SessionId1Bin = list_to_binary(Session1Id),
105 | Uri4 = binary_to_list(<<"/sessions/", SessionId1Bin/binary>>),
106 | #{status_code := 204} = sr_test_utils:api_call(delete, Uri4, Headers),
107 |
108 | ct:comment("One session again"),
109 | [Session3] = sumo:find_all(sessions),
110 |
111 | ct:comment("DELETE is not idempotent"),
112 | SessionId3Bin = list_to_binary(Session3Id),
113 | Uri5 = binary_to_list(<<"/sessions/", SessionId3Bin/binary>>),
114 | #{status_code := 204} = sr_test_utils:api_call(delete, Uri5, Headers),
115 | #{status_code := 404} = sr_test_utils:api_call(delete, Uri5, Headers),
116 |
117 | ct:comment("There are no sessions"),
118 | [] = sumo:find_all(sessions),
119 |
120 | {comment, ""}.
121 |
122 | -spec invalid_auth(sr_test_utils:config()) -> {comment, string()}.
123 | invalid_auth(Config) ->
124 | Uri = "/sessions",
125 |
126 | ct:comment("Can't use POST, PUT nor DELETE without auth"),
127 | #{status_code := 401} = sr_test_utils:api_call(post, Uri),
128 | #{status_code := 401} = sr_test_utils:api_call(put, Uri ++ "/noauth"),
129 | #{status_code := 401} = sr_test_utils:api_call(delete, Uri ++ "/noauth"),
130 |
131 | ct:comment("Can't use POST, PUT nor DELETE without Basic auth"),
132 | Headers1 = #{<<"Authorization">> => <<"Bearer iSnotAGoodThing">>},
133 | #{status_code := 401} = sr_test_utils:api_call(post, Uri, Headers1),
134 | #{status_code := 401} =
135 | sr_test_utils:api_call(put, Uri ++ "/other", Headers1),
136 | #{status_code := 401} =
137 | sr_test_utils:api_call(delete, Uri ++ "/other", Headers1),
138 |
139 | ct:comment("Can't use POST, PUT nor DELETE with broken Basic auth"),
140 | Headers2 = #{<<"Authorization">> => <<"Basic ThisIsNotBase64">>},
141 | #{status_code := 401} = sr_test_utils:api_call(post, Uri, Headers2),
142 | #{status_code := 401} =
143 | sr_test_utils:api_call(put, Uri ++ "/broken", Headers2),
144 | #{status_code := 401} =
145 | sr_test_utils:api_call(delete, Uri ++ "/broken", Headers2),
146 |
147 | ct:comment("Can't use POST, PUT nor DELETE with wrong user"),
148 | Headers3 = #{basic_auth => {"not-user", "pwd"}},
149 | #{status_code := 401} = sr_test_utils:api_call(post, Uri, Headers3),
150 | #{status_code := 401} =
151 | sr_test_utils:api_call(put, Uri ++ "/not-user", Headers3),
152 | #{status_code := 401} =
153 | sr_test_utils:api_call(delete, Uri ++ "/not-user", Headers3),
154 |
155 | ct:comment("Can't use POST, PUT nor DELETE with wrong password"),
156 | {basic_auth, {U, _}} = lists:keyfind(basic_auth, 1, Config),
157 | Headers4 = #{basic_auth => {U, "not-password"}},
158 | #{status_code := 401} = sr_test_utils:api_call(post, Uri, Headers4),
159 | #{status_code := 401} =
160 | sr_test_utils:api_call(put, Uri ++ "/wrong-token", Headers4),
161 | #{status_code := 401} =
162 | sr_test_utils:api_call(delete, Uri ++ "/wrong-token", Headers4),
163 |
164 | ct:comment("Sessions can only be modified or deleted by their user"),
165 | [_, {User2Name, _} | _] = application:get_env(sr_test, users, []),
166 | SessionId =
167 | sr_sessions:unique_id(sumo:persist(sessions, sr_sessions:new(User2Name))),
168 | SessionIdBin = list_to_binary(SessionId),
169 | ForbiddenUri = binary_to_list(<<"/sessions/", SessionIdBin/binary>>),
170 | {basic_auth, BasicAuth} = lists:keyfind(basic_auth, 1, Config),
171 | Headers5 = #{basic_auth => BasicAuth},
172 | #{status_code := 403} = sr_test_utils:api_call(put, ForbiddenUri, Headers5),
173 | #{status_code := 403} =
174 | sr_test_utils:api_call(delete, ForbiddenUri, Headers5),
175 |
176 | {comment, ""}.
177 |
178 | -spec invalid_headers(sr_test_utils:config()) -> {comment, string()}.
179 | invalid_headers(Config) ->
180 | {basic_auth, BasicAuth} = lists:keyfind(basic_auth, 1, Config),
181 | NoHeaders = #{basic_auth => BasicAuth},
182 | InvalidHeaders = #{ basic_auth => BasicAuth
183 | , <<"content-type">> => <<"text/plain">>
184 | },
185 | InvalidAccept = #{ basic_auth => BasicAuth
186 | , <<"content-type">> => <<"application/json">>
187 | , <<"accept">> => <<"text/html">>
188 | },
189 |
190 | {User, _} = first_user(),
191 | SessionId =
192 | sr_sessions:unique_id(sumo:persist(sessions, sr_sessions:new(User))),
193 | SessionIdBin = list_to_binary(SessionId),
194 | SessionUri = binary_to_list(<<"/sessions/", SessionIdBin/binary>>),
195 |
196 | ct:comment("content-type must be provided for POST and PUT"),
197 | #{status_code := 415} =
198 | sr_test_utils:api_call(post, "/sessions", NoHeaders, <<>>),
199 | #{status_code := 415} =
200 | sr_test_utils:api_call(put, SessionUri, NoHeaders, <<>>),
201 |
202 | ct:comment("content-type must be JSON for POST and PUT"),
203 | #{status_code := 415} =
204 | sr_test_utils:api_call(post, "/sessions", InvalidHeaders, <<>>),
205 | #{status_code := 415} =
206 | sr_test_utils:api_call(put, SessionUri, InvalidHeaders, <<>>),
207 |
208 | ct:comment("Agent must accept json for POST and PUT"),
209 | #{status_code := 406} =
210 | sr_test_utils:api_call(post, "/sessions", InvalidAccept, <<>>),
211 | #{status_code := 406} =
212 | sr_test_utils:api_call(put, SessionUri, InvalidAccept, <<>>),
213 |
214 | {comment, ""}.
215 |
216 | -spec invalid_parameters(sr_test_utils:config()) -> {comment, string()}.
217 | invalid_parameters(Config) ->
218 | {basic_auth, BasicAuth} = lists:keyfind(basic_auth, 1, Config),
219 | Headers = #{ basic_auth => BasicAuth
220 | , <<"content-type">> => <<"application/json">>
221 | },
222 |
223 | {User, _} = first_user(),
224 | SessionId =
225 | sr_sessions:unique_id(sumo:persist(sessions, sr_sessions:new(User))),
226 | SessionIdBin = list_to_binary(SessionId),
227 | SessionUri = binary_to_list(<<"/sessions/", SessionIdBin/binary>>),
228 |
229 | ct:comment("Empty or broken parameters are reported"),
230 | #{status_code := 400} =
231 | sr_test_utils:api_call(post, "/sessions", Headers, <<>>),
232 | #{status_code := 400} =
233 | sr_test_utils:api_call(put, SessionUri, Headers, <<>>),
234 | #{status_code := 400} =
235 | sr_test_utils:api_call(post, "/sessions", Headers, <<"{">>),
236 | #{status_code := 400} =
237 | sr_test_utils:api_call(put, SessionUri, Headers, <<"{">>),
238 |
239 | {comment, ""}.
240 |
241 | -spec conflict(sr_test_utils:config()) -> {comment, string()}.
242 | conflict(Config) ->
243 | {basic_auth, BasicAuth} = lists:keyfind(basic_auth, 1, Config),
244 | Headers = #{ basic_auth => BasicAuth
245 | , <<"content-type">> => <<"application/json">>
246 | },
247 |
248 | ct:comment("Can't update unexisting session"),
249 | #{status_code := 409} =
250 | sr_test_utils:api_call(put, "/sessions/notfound", Headers, #{}),
251 | {comment, ""}.
252 |
253 | -spec location(st_test_utils:config()) -> {comment, string()}.
254 | location(Config) ->
255 | {basic_auth, BasicAuth} = lists:keyfind(basic_auth, 1, Config),
256 | Headers = #{ basic_auth => BasicAuth
257 | , <<"content-type">> => <<"application/json">>
258 | },
259 |
260 | ct:comment("A session is created"),
261 | #{status_code := 201, body := Body1, headers := ResponseHeaders} =
262 | sr_test_utils:api_call(post, "/sessions", Headers, #{agent => <<"a1">>}),
263 | #{ <<"id">> := Session1Id
264 | , <<"token">> := _Token1
265 | , <<"agent">> := <<"a1">>
266 | , <<"created_at">> := _CreatedAt1
267 | , <<"expires_at">> := _ExpiresAt1
268 | } = sr_json:decode(Body1),
269 | ct:comment("and its location header is set correctly"),
270 | Location = proplists:get_value(<<"location">>, ResponseHeaders),
271 | SessionId2Bin = list_to_binary(Session1Id),
272 | Location = <<"/sessions/", SessionId2Bin/binary>>,
273 |
274 | {comment, ""}.
275 |
276 | %% @private
277 | first_user() ->
278 | [{U, P}|_] = application:get_env(sr_test, users, []),
279 | {U, P}.
280 |
--------------------------------------------------------------------------------
/test/sr_state_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(sr_state_SUITE).
2 |
3 | -export([all/0]).
4 | -export([state/1]).
5 |
6 | -spec all() -> [atom()].
7 | all() -> sr_test_utils:all(?MODULE).
8 |
9 |
10 | -spec state(sr_test_utils:config()) -> {comment, string()}.
11 | state(_Config) ->
12 | ct:comment("Testing sr_state functions"),
13 | Module = fake_module_name,
14 | Path = "/path",
15 | Opts = #{model => value1, path => Path},
16 | SrState = sr_state:new(Opts, Module),
17 | Module = sr_state:module(SrState),
18 | Path = sr_state:path(SrState),
19 | Opts = sr_state:opts(SrState),
20 | undefined = sr_state:id(SrState),
21 | Id = <<"myId">>,
22 | SrState2 = sr_state:id(SrState, Id),
23 | Id = sr_state:id(SrState2),
24 | SrState3 = sr_state:set(key, value, SrState2),
25 | value = sr_state:retrieve(key, SrState3, not_found),
26 | SrState4 = sr_state:remove(key, SrState3),
27 | not_found = sr_state:retrieve(key, SrState4, not_found),
28 | Entity = #{},
29 | undefined = sr_state:entity(SrState4),
30 | SrState5 = sr_state:entity(SrState4, Entity),
31 | Entity = sr_state:entity(SrState5),
32 |
33 | {comment, ""}.
34 |
--------------------------------------------------------------------------------
/test/sr_test.app:
--------------------------------------------------------------------------------
1 | {application, sr_test, [
2 | {description, "Test app for sumo_rest"},
3 | {id, "sr_test"},
4 | {applications,
5 | [ kernel
6 | , stdlib
7 | , crypto
8 | , sasl
9 | , cowboy
10 | , cowboy_swagger
11 | , jsx
12 | , sumo_db
13 | , mnesia
14 | ]},
15 | {modules, []},
16 | {mod, {sr_test, []}},
17 | {registered, []},
18 | { start_phases
19 | , [ {create_schema, []}
20 | , {start_cowboy_listeners, []}
21 | ]
22 | }
23 | ]}.
24 |
--------------------------------------------------------------------------------
/test/sr_test/sr_echo_request.erl:
--------------------------------------------------------------------------------
1 | %%% @doc echo_request model. This module is only for testing purposes
2 | -module(sr_echo_request).
3 |
4 | -behaviour(sumo_doc).
5 | -behaviour(sumo_rest_doc).
6 |
7 | -opaque echo_request() ::
8 | #{ id := undefined | binary()
9 | , headers := any()
10 | , path := any()
11 | , bindings := any()
12 | }.
13 |
14 | -export_type([echo_request/0]).
15 |
16 | -export(
17 | [ sumo_schema/0
18 | , sumo_wakeup/1
19 | , sumo_sleep/1
20 | ]).
21 | -export(
22 | [ to_json/1
23 | , from_ctx/1
24 | , location/2
25 | , update/2
26 | ]).
27 |
28 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
29 | %% BEHAVIOUR CALLBACKS
30 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
31 |
32 | -spec sumo_schema() -> sumo:schema().
33 | sumo_schema() ->
34 | sumo:new_schema(echo_request,
35 | [ sumo:new_field(id, string, [id, not_null])
36 | , sumo:new_field(headers, custom, [not_null])
37 | , sumo:new_field(path, binary, [not_null])
38 | , sumo:new_field(bindings, custom, [not_null])
39 | ]).
40 |
41 | -spec sumo_sleep(echo_request()) -> sumo:model().
42 | sumo_sleep(EchoRequest) -> EchoRequest.
43 |
44 | -spec sumo_wakeup(sumo:model()) -> echo_request().
45 | sumo_wakeup(EchoRequest) -> EchoRequest.
46 |
47 | -spec to_json(echo_request()) -> sr_json:json().
48 | to_json(EchoRequest) ->
49 | #{ id => maps:get(id, EchoRequest)
50 | , headers => maps:from_list(maps:get(headers, EchoRequest))
51 | , path => maps:get(path, EchoRequest)
52 | , bindings => maps:get(bindings, EchoRequest)
53 | }.
54 |
55 | -spec update(echo_request(), sumo_rest_doc:json()) ->
56 | {ok, echo_request()} | {error, iodata()}.
57 | update(EchoRequest, _Json) ->
58 | EchoRequest.
59 |
60 | -spec location(echo_request(), sumo_rest_doc:path()) -> binary().
61 | location(#{id := Id}, Path) ->
62 | iolist_to_binary([Path, "/", Id]).
63 |
64 | -spec from_ctx(sumo_rest_doc:context()) -> {ok, echo_request()}.
65 | from_ctx(#{req := Req, state := State}) ->
66 | {ok, #{ id => sr_state:id(State)
67 | , headers => sr_request:headers(Req)
68 | , path => sr_request:path(Req)
69 | , bindings => sr_request:bindings(Req)
70 | }}.
71 |
--------------------------------------------------------------------------------
/test/sr_test/sr_echo_request_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc /echo handler
2 | -module(sr_echo_request_handler).
3 |
4 | -behaviour(trails_handler).
5 |
6 | -include_lib("mixer/include/mixer.hrl").
7 | -mixin([{ sr_single_entity_handler
8 | , [ init/3
9 | , rest_init/2
10 | , allowed_methods/2
11 | , resource_exists/2
12 | , content_types_accepted/2
13 | , content_types_provided/2
14 | , handle_put/2
15 | ]
16 | }]).
17 |
18 | -export([ trails/0
19 | ]).
20 |
21 | -spec trails() -> trails:trails().
22 | trails() ->
23 | RequestBody =
24 | #{ name => <<"request body">>
25 | , in => body
26 | , description => <<"request body (as json)">>
27 | , required => true
28 | },
29 | Metadata =
30 | #{ put =>
31 | #{ tags => ["echo"]
32 | , description => "save an echo request"
33 | , consumes => ["application/json"]
34 | , produces => ["application/json"]
35 | , parameters => [RequestBody]
36 | }
37 | },
38 | Path = "/echo/:id",
39 | Opts = #{ path => Path
40 | , model => echo_request
41 | , verbose => true
42 | },
43 | [trails:trail(Path, ?MODULE, Opts, Metadata)].
44 |
--------------------------------------------------------------------------------
/test/sr_test/sr_elements.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Elements Model
2 | -module(sr_elements).
3 |
4 | -behaviour(sumo_doc).
5 | -behaviour(sumo_rest_doc).
6 |
7 | -type key() :: integer().
8 | -type value() :: binary() | iodata().
9 |
10 | -opaque element() ::
11 | #{ key => key()
12 | , value => value()
13 | , created_at => calendar:datetime()
14 | , updated_at => calendar:datetime()
15 | }.
16 |
17 | -export_type(
18 | [ element/0
19 | , key/0
20 | , value/0
21 | ]).
22 |
23 | -export(
24 | [ sumo_schema/0
25 | , sumo_wakeup/1
26 | , sumo_sleep/1
27 | ]).
28 | -export(
29 | [ new/2
30 | , key/1
31 | , value/1
32 | , updated_at/1
33 | ]).
34 | -export(
35 | [ to_json/1
36 | , from_json/1
37 | , location/2
38 | , duplication_conditions/1
39 | , update/2
40 | , id_from_binding/1
41 | ]).
42 |
43 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
44 | %% BEHAVIOUR CALLBACKS
45 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
46 |
47 | -spec sumo_schema() -> sumo:schema().
48 | sumo_schema() ->
49 | sumo:new_schema(elements,
50 | [ sumo:new_field(key, integer, [id, not_null])
51 | , sumo:new_field(value, string, [not_null])
52 | , sumo:new_field(created_at, datetime, [not_null])
53 | , sumo:new_field(updated_at, datetime, [not_null])
54 | ]).
55 |
56 | -spec sumo_sleep(element()) -> sumo:model().
57 | sumo_sleep(Element) -> Element.
58 |
59 | -spec sumo_wakeup(sumo:model()) -> element().
60 | sumo_wakeup(Element) -> Element.
61 |
62 | -spec to_json(element()) -> sr_json:json().
63 | to_json(Element) ->
64 | #{ key => maps:get(key, Element)
65 | , value => maps:get(value, Element)
66 | , created_at => sr_json:encode_date(maps:get(created_at, Element))
67 | , updated_at => sr_json:encode_date(maps:get(updated_at, Element))
68 | }.
69 |
70 | -spec from_json(sumo_rest_doc:json()) -> {ok, element()} | {error, iodata()}.
71 | from_json(Json) ->
72 | Now = sr_json:encode_date(calendar:universal_time()),
73 | try
74 | { ok
75 | , #{ key => maps:get(<<"key">>, Json)
76 | , value => maps:get(<<"value">>, Json)
77 | , created_at =>
78 | sr_json:decode_date(maps:get(<<"created_at">>, Json, Now))
79 | , updated_at =>
80 | sr_json:decode_date(maps:get(<<"updated_at">>, Json, Now))
81 | }
82 | }
83 | catch
84 | _:{badkey, Key} ->
85 | {error, <<"missing field: ", Key/binary>>}
86 | end.
87 |
88 | -spec update(element(), sumo_rest_doc:json()) ->
89 | {ok, element()} | {error, iodata()}.
90 | update(Element, Json) ->
91 | try
92 | NewValue = maps:get(<<"value">>, Json),
93 | UpdatedElement =
94 | Element#{value := NewValue, updated_at := calendar:universal_time()},
95 | {ok, UpdatedElement}
96 | catch
97 | _:{badkey, Key} ->
98 | {error, <<"missing field: ", Key/binary>>}
99 | end.
100 |
101 | -spec location(element(), sumo_rest_doc:path()) -> binary().
102 | location(Element, Path) -> iolist_to_binary([Path, "/", key(Element)]).
103 |
104 | -spec duplication_conditions(element()) ->
105 | sumo_rest_doc:duplication_conditions().
106 | duplication_conditions(Element) -> [{key, '==', key(Element)}].
107 |
108 | %% this function is implemented for testing purposes. If your key is integer,
109 | %% binary or string this function is not needed.
110 | -spec id_from_binding(binary()) -> key().
111 | id_from_binding(BinaryId) ->
112 | try binary_to_integer(BinaryId) of
113 | Id -> Id
114 | catch
115 | error:badarg -> -1
116 | end.
117 |
118 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
119 | %% PUBLIC API
120 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
121 |
122 | -spec new(key(), value()) -> element().
123 | new(Key, Value) ->
124 | Now = calendar:universal_time(),
125 | #{ key => Key
126 | , value => Value
127 | , created_at => Now
128 | , updated_at => Now
129 | }.
130 |
131 | -spec key(element()) -> key().
132 | key(#{key := Key}) -> Key.
133 |
134 | -spec value(element()) -> value().
135 | value(#{value := Value}) -> Value.
136 |
137 | -spec updated_at(element()) -> calendar:datetime().
138 | updated_at(#{updated_at := UpdatedAt}) -> UpdatedAt.
139 |
--------------------------------------------------------------------------------
/test/sr_test/sr_elements_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc POST|GET /elements handler
2 | -module(sr_elements_handler).
3 |
4 | -behaviour(trails_handler).
5 |
6 | -include_lib("mixer/include/mixer.hrl").
7 | -mixin([{ sr_entities_handler
8 | , [ init/3
9 | , rest_init/2
10 | , allowed_methods/2
11 | , resource_exists/2
12 | , content_types_accepted/2
13 | , content_types_provided/2
14 | , handle_get/2
15 | , handle_post/2
16 | ]
17 | }]).
18 |
19 | -export([ trails/0
20 | ]).
21 |
22 | -spec trails() -> trails:trails().
23 | trails() ->
24 | RequestBody =
25 | #{ name => <<"request body">>
26 | , in => body
27 | , description => <<"request body (as json)">>
28 | , required => true
29 | },
30 | Metadata =
31 | #{ get =>
32 | #{ tags => ["elements"]
33 | , description => "Returns the list of elements"
34 | , produces => ["application/json"]
35 | }
36 | , post =>
37 | #{ tags => ["elements"]
38 | , description => "Creates a new element"
39 | , consumes => ["application/json", "application/json; charset=utf-8"]
40 | , produces => ["application/json"]
41 | , parameters => [RequestBody]
42 | }
43 | , put =>
44 | #{ tags => ["elements"]
45 | , description => "Updates an element"
46 | , produces => ["application/json"]
47 | , parameters => [RequestBody]
48 | }
49 | },
50 | Path = "/elements",
51 | Opts = #{ path => Path
52 | , model => elements
53 | , verbose => true
54 | },
55 | [trails:trail(Path, ?MODULE, Opts, Metadata)].
56 |
--------------------------------------------------------------------------------
/test/sr_test/sr_sessions.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Sessions Model
2 | -module(sr_sessions).
3 |
4 | -behaviour(sumo_doc).
5 | -behaviour(sumo_rest_doc).
6 |
7 | -type id() :: binary().
8 | -type token() :: binary().
9 | -type user() :: binary().
10 | -type agent() :: binary().
11 |
12 | %% Change type per opaque when https://bugs.erlang.org/browse/ERL-249
13 | -type session() ::
14 | #{ id => undefined | id()
15 | , token => token()
16 | , agent => undefined | agent()
17 | , user => user()
18 | , created_at => calendar:datetime()
19 | , expires_at => calendar:datetime()
20 | }.
21 |
22 | -export_type(
23 | [ session/0
24 | , id/0
25 | , token/0
26 | , agent/0
27 | ]).
28 |
29 | -export(
30 | [ sumo_schema/0
31 | , sumo_wakeup/1
32 | , sumo_sleep/1
33 | ]).
34 | -export(
35 | [ new/1
36 | , unique_id/1
37 | , token/1
38 | , user/1
39 | , user/2
40 | , expires_at/1
41 | ]).
42 | -export(
43 | [ to_json/1
44 | , from_ctx/1
45 | , from_json/2
46 | , location/2
47 | , update/2
48 | ]).
49 |
50 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
51 | %% BEHAVIOUR CALLBACKS
52 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
53 |
54 | -spec sumo_schema() -> sumo:schema().
55 | sumo_schema() ->
56 | sumo:new_schema(sessions,
57 | [ sumo:new_field(id, string, [id, not_null])
58 | , sumo:new_field(token, binary, [not_null])
59 | , sumo:new_field(agent, binary, [])
60 | , sumo:new_field(user, binary, [not_null])
61 | , sumo:new_field(created_at, datetime, [not_null])
62 | , sumo:new_field(expires_at, datetime, [not_null])
63 | ]).
64 |
65 | -spec sumo_sleep(session()) -> sumo:model().
66 | sumo_sleep(Session) -> Session.
67 |
68 | -spec sumo_wakeup(sumo:model()) -> session().
69 | sumo_wakeup(Session) -> Session.
70 |
71 | -spec to_json(session()) -> sr_json:json().
72 | to_json(Session) ->
73 | #{ id => sr_json:encode_null(maps:get(id, Session))
74 | , token => maps:get(token, Session)
75 | , agent => sr_json:encode_null(maps:get(agent, Session))
76 | , created_at => sr_json:encode_date(maps:get(created_at, Session))
77 | , expires_at => sr_json:encode_date(maps:get(expires_at, Session))
78 | }.
79 |
80 | -spec from_json(id(), sumo_rest_doc:json()) ->
81 | {ok, session()} | {error, iodata()}.
82 | from_json(Id, Json) -> from_json_internal(Json#{<<"id">> => Id}).
83 |
84 | -spec from_json_internal(sumo_rest_doc:json()) ->
85 | {ok, session()} | {error, iodata()}.
86 | from_json_internal(Json) ->
87 | Now = sr_json:encode_date(calendar:universal_time()),
88 | ExpiresAt = sr_json:encode_date(expires_at()),
89 | try
90 | { ok
91 | , #{ id => sr_json:decode_null(maps:get(<<"id">>, Json, null))
92 | , token => maps:get(<<"token">>, Json, generate_token())
93 | , agent => sr_json:decode_null(maps:get(<<"agent">>, Json, null))
94 | , created_at =>
95 | sr_json:decode_date(maps:get(<<"created_at">>, Json, Now))
96 | , expires_at =>
97 | sr_json:decode_date(maps:get(<<"expires_at">>, Json, ExpiresAt))
98 | }
99 | }
100 | catch
101 | _:{badkey, Key} ->
102 | {error, <<"missing field: ", Key/binary>>}
103 | end.
104 |
105 | -spec from_ctx(sumo_rest_doc:context()) -> {ok, session()} | {error, iodata()}.
106 | from_ctx(#{req := SrRequest, state := State}) ->
107 | Json = sr_request:body(SrRequest),
108 | {User, _} = sr_state:retrieve(user, State, undefined),
109 | case from_json_internal(Json) of
110 | {ok, Session} -> {ok, user(Session, User)};
111 | MissingField -> MissingField
112 | end.
113 |
114 | -spec update(session(), sumo_rest_doc:json()) ->
115 | {ok, session()} | {error, iodata()}.
116 | update(Session, Json) ->
117 | #{id := SessionId} = Session,
118 | case from_json(SessionId, Json) of
119 | {error, Reason} -> {error, Reason};
120 | {ok, Updates} ->
121 | UpdatedSession = maps:merge(Session, Updates),
122 | {ok, UpdatedSession#{ expires_at => expires_at()
123 | , token => generate_token()
124 | }}
125 | end.
126 |
127 | -spec location(session(), sumo_rest_doc:path()) -> binary().
128 | location(Session, Path) -> iolist_to_binary([Path, "/", unique_id(Session)]).
129 |
130 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
131 | %% PUBLIC API
132 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
133 |
134 | -spec new(user()) -> session().
135 | new(User) ->
136 | Now = calendar:universal_time(),
137 | #{ id => undefined
138 | , user => User
139 | , agent => undefined
140 | , token => generate_token()
141 | , created_at => Now
142 | , expires_at => expires_at()
143 | }.
144 |
145 | unique_id(#{id := Id}) -> Id.
146 |
147 | -spec token(session()) -> token().
148 | token(#{token := Token}) -> Token.
149 |
150 | -spec user(session()) -> user().
151 | user(#{user := User}) -> User.
152 |
153 | -spec user(session(), user()) -> session().
154 | user(Session, User) -> Session#{user => User}.
155 |
156 | -spec expires_at(session()) -> calendar:datetime().
157 | expires_at(#{expires_at := ExpiresAt}) -> ExpiresAt.
158 |
159 | generate_token() -> base64:encode(crypto:strong_rand_bytes(32)).
160 |
161 | expires_at() ->
162 | calendar:gregorian_seconds_to_datetime(
163 | calendar:datetime_to_gregorian_seconds(
164 | calendar:universal_time()) + 60000).
165 |
--------------------------------------------------------------------------------
/test/sr_test/sr_sessions_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc POST /sessions handler
2 | -module(sr_sessions_handler).
3 |
4 | -behaviour(trails_handler).
5 |
6 | -include_lib("mixer/include/mixer.hrl").
7 | -mixin([{ sr_entities_handler
8 | , [ init/3
9 | , rest_init/2
10 | , allowed_methods/2
11 | , resource_exists/2
12 | , content_types_accepted/2
13 | , content_types_provided/2
14 | , handle_post/2
15 | ]
16 | }]).
17 |
18 | -export([ trails/0
19 | , is_authorized/2
20 | ]).
21 |
22 | -type state() :: sr_entities_handler:state().
23 |
24 | -spec trails() -> trails:trails().
25 | trails() ->
26 | RequestBody =
27 | #{ name => <<"request body">>
28 | , in => body
29 | , description => <<"request body (as json)">>
30 | , required => true
31 | },
32 | Metadata =
33 | #{ post =>
34 | #{ tags => ["sessions"]
35 | , description => "Creates a new session"
36 | , consumes => ["application/json"]
37 | , produces => ["application/json"]
38 | , parameters => [RequestBody]
39 | }
40 | },
41 | Path = "/sessions",
42 | Opts = #{ path => Path
43 | , model => sessions
44 | , verbose => true
45 | },
46 | [trails:trail(Path, ?MODULE, Opts, Metadata)].
47 |
48 | -spec is_authorized(cowboy_req:req(), state()) ->
49 | {boolean(), cowboy_req:req(), state()}.
50 | is_authorized(Req, State) ->
51 | case get_authorization(Req) of
52 | {not_authenticated, Req1} ->
53 | {{false, auth_header()}, Req1, State};
54 | {User, Req1} ->
55 | Users = application:get_env(sr_test, users, []),
56 | case lists:member(User, Users) of
57 | true ->
58 | {true, Req1, sr_state:set(user, User, State)};
59 | false ->
60 | ct:log("Invalid user ~p not in ~p", [User, Users]),
61 | {{false, auth_header()}, Req1, State}
62 | end
63 | end.
64 |
65 | -spec get_authorization(cowboy_req:req()) ->
66 | {{binary(), binary()}, cowboy_req:req()}
67 | | {not_authenticated, cowboy_req:req()}.
68 | get_authorization(Req) ->
69 | try cowboy_req:parse_header(<<"authorization">>, Req) of
70 | {ok, {<<"basic">>, {Key, Secret}}, Req1} ->
71 | {{Key, Secret}, Req1};
72 | {ok, Value, Req1} ->
73 | WarnMsg = "Invalid basic authentication: ~p~n",
74 | error_logger:warning_msg(WarnMsg, [Value]),
75 | {not_authenticated, Req1};
76 | {error, badarg} ->
77 | {Hdr, Req1} = cowboy_req:header(<<"authorization">>, Req),
78 | WarnMsg = "Malformed authorization header: ~p~n",
79 | error_logger:warning_msg(WarnMsg, [Hdr]),
80 | {not_authenticated, Req1}
81 | catch
82 | _:Error ->
83 | WarnMsg = "Error trying to parse auth: ~p~nStack: ~s",
84 | error_logger:warning_msg(WarnMsg, [Error, erlang:get_stacktrace()]),
85 | {not_authenticated, Req}
86 | end.
87 |
88 | -spec auth_header() -> binary().
89 | auth_header() -> <<"Basic Realm=\"Sumo Rest Test\"">>.
90 |
--------------------------------------------------------------------------------
/test/sr_test/sr_single_element_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc GET|PUT|DELETE /elements/:id handler
2 | -module(sr_single_element_handler).
3 |
4 | -behaviour(trails_handler).
5 |
6 | -include_lib("mixer/include/mixer.hrl").
7 | -mixin([{ sr_single_entity_handler
8 | , [ init/3
9 | , rest_init/2
10 | , allowed_methods/2
11 | , resource_exists/2
12 | , content_types_accepted/2
13 | , content_types_provided/2
14 | , handle_get/2
15 | , handle_put/2
16 | , handle_patch/2
17 | , delete_resource/2
18 | ]
19 | }]).
20 |
21 | -export([ trails/0
22 | ]).
23 |
24 | -spec trails() -> trails:trails().
25 | trails() ->
26 | RequestBody =
27 | #{ name => <<"request body">>
28 | , in => body
29 | , description => <<"request body (as json)">>
30 | , required => true
31 | },
32 | Id =
33 | #{ name => id
34 | , in => path
35 | , description => <<"Element Key">>
36 | , required => true
37 | , type => string
38 | },
39 | Metadata =
40 | #{ get =>
41 | #{ tags => ["elements"]
42 | , description => "Returns an element"
43 | , produces => ["application/json"]
44 | , parameters => [Id]
45 | }
46 | , patch =>
47 | #{ tags => ["elements"]
48 | , description => "Updates an element"
49 | , consumes => ["application/json", "application/json; charset=utf-8"]
50 | , produces => ["application/json"]
51 | , parameters => [RequestBody, Id]
52 | }
53 | , put =>
54 | #{ tags => ["elements"]
55 | , description => "Updates or creates a new element"
56 | , consumes => ["application/json", "application/json; charset=utf-8"]
57 | , produces => ["application/json"]
58 | , parameters => [RequestBody, Id]
59 | }
60 | , delete =>
61 | #{ tags => ["elements"]
62 | , description => "Deletes an element"
63 | , parameters => [Id]
64 | }
65 | },
66 | Path = "/elements/:id",
67 | Opts = #{ path => Path
68 | , model => elements
69 | , verbose => true
70 | },
71 | [trails:trail(Path, ?MODULE, Opts, Metadata)].
72 |
--------------------------------------------------------------------------------
/test/sr_test/sr_single_session_handler.erl:
--------------------------------------------------------------------------------
1 | %%% @doc GET|DELETE /sessions/:id handler
2 | -module(sr_single_session_handler).
3 |
4 | -behaviour(trails_handler).
5 |
6 | -include_lib("mixer/include/mixer.hrl").
7 | -mixin([{ sr_single_entity_handler
8 | , [ init/3
9 | , rest_init/2
10 | , allowed_methods/2
11 | , resource_exists/2
12 | , content_types_accepted/2
13 | , content_types_provided/2
14 | , handle_get/2
15 | , handle_put/2
16 | , delete_resource/2
17 | ]
18 | }]).
19 | -mixin([{ sr_sessions_handler
20 | , [ is_authorized/2
21 | ]
22 | }]).
23 |
24 | -export([ trails/0
25 | , forbidden/2
26 | , is_conflict/2
27 | ]).
28 |
29 | -type state() :: sr_single_entity_handler:state().
30 |
31 | -spec trails() -> trails:trails().
32 | trails() ->
33 | RequestBody =
34 | #{ name => <<"request body">>
35 | , in => body
36 | , description => <<"request body (as json)">>
37 | , required => true
38 | },
39 | Id =
40 | #{ name => id
41 | , in => path
42 | , description => <<"Session Id">>
43 | , required => true
44 | , type => string
45 | },
46 | Metadata =
47 | #{ put =>
48 | #{ tags => ["sessions"]
49 | , description => "Updates a session"
50 | , consumes => ["application/json"]
51 | , produces => ["application/json"]
52 | , parameters => [RequestBody, Id]
53 | }
54 | , delete =>
55 | #{ tags => ["sessions"]
56 | , description => "Deletes a session"
57 | , parameters => [Id]
58 | }
59 | },
60 | Path = "/sessions/:id",
61 | Opts = #{ path => Path
62 | , model => sessions
63 | , verbose => true
64 | },
65 | [trails:trail(Path, ?MODULE, Opts, Metadata)].
66 |
67 | -spec forbidden(cowboy_req:req(), state()) ->
68 | {boolean(), cowboy_req:req(), state()}.
69 | forbidden(Req, State) ->
70 | {User, _} = sr_state:retrieve(user, State, undefined),
71 | Id = sr_state:id(State),
72 | case sumo:fetch(sessions, Id) of
73 | notfound -> {false, Req, State};
74 | Session -> {User =/= sr_sessions:user(Session), Req, State}
75 | end.
76 |
77 | -spec is_conflict(cowboy_req:req(), state()) ->
78 | {boolean(), cowboy_req:req(), state()}.
79 | is_conflict(Req, State) ->
80 | Entity = sr_state:entity(State),
81 | {Entity =:= undefined, Req, State}.
82 |
--------------------------------------------------------------------------------
/test/sr_test/sr_test.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Main Application module
2 | -module(sr_test).
3 |
4 | -behaviour(application).
5 |
6 | -export([ start/2
7 | , start_phase/3
8 | , stop/1
9 | ]).
10 |
11 | -spec start(application:start_type(), any()) -> {ok, pid()}.
12 | start(_StartType, _Args) ->
13 | _ = application:stop(lager),
14 | ok = application:stop(sasl),
15 | {ok, _} = application:ensure_all_started(sasl),
16 | {ok, self()}.
17 |
18 | -spec start_phase(atom(), application:start_type(), []) -> ok | {error, _}.
19 | start_phase(create_schema, _StartType, []) ->
20 | _ = application:stop(mnesia),
21 | Node = node(),
22 | case mnesia:create_schema([Node]) of
23 | ok -> ok;
24 | {error, {Node, {already_exists, Node}}} -> ok
25 | end,
26 | {ok, _} = application:ensure_all_started(mnesia),
27 | sumo:create_schema();
28 | start_phase(start_cowboy_listeners, _StartType, []) ->
29 | Handlers =
30 | [ sr_elements_handler
31 | , sr_single_element_handler
32 | , sr_sessions_handler
33 | , sr_single_session_handler
34 | , sr_echo_request_handler % only for testing
35 | , cowboy_swagger_handler
36 | ],
37 | Routes = trails:trails(Handlers),
38 | trails:store(Routes),
39 | Dispatch = trails:single_host_compile(Routes),
40 |
41 | TransOpts = [{port, 4891}],
42 | ProtoOpts = %% cowboy_protocol:opts()
43 | [{compress, true}, {env, [{dispatch, Dispatch}]}],
44 | case cowboy:start_http(sr_test_server, 1, TransOpts, ProtoOpts) of
45 | {ok, _} -> ok;
46 | {error, {already_started, _}} -> ok
47 | end.
48 |
49 | -spec stop(atom()) -> ok.
50 | stop(_State) -> ok.
51 |
--------------------------------------------------------------------------------
/test/test.config:
--------------------------------------------------------------------------------
1 | [ { sasl
2 | , [ {error_logger_mf_dir, "log/sasl"}
3 | , {error_logger_mf_maxfiles, 10}
4 | , {error_logger_mf_maxbytes, 1000000}
5 | , {errlog_type, error}
6 | ]
7 | }
8 | , { cowboy_swagger
9 | , [ { global_spec
10 | , #{ swagger => "2.0"
11 | , info => #{title => "SumoRest Test API"}
12 | , basePath => ""
13 | }
14 | }
15 | ]
16 | }
17 | , { mnesia
18 | , [{debug, true}]
19 | }
20 | , { sumo_db
21 | , [ {wpool_opts, [{overrun_warning, 100}]}
22 | , {log_queries, true}
23 | , {query_timeout, 30000}
24 | , {storage_backends, []}
25 | , {stores, [
26 | {sr_store_mnesia, sumo_store_mnesia, [
27 | {workers, 10},
28 | {ram_copies, here},
29 | {majority, false}
30 | ]}
31 | ]}
32 | , { docs
33 | , [ {elements, sr_store_mnesia, #{module => sr_elements}}
34 | , {sessions, sr_store_mnesia, #{module => sr_sessions}}
35 | , {echo_request, sr_store_mnesia, #{module => sr_echo_request}}
36 | ]
37 | }
38 | , {events, []}
39 | ]
40 | }
41 | , { sr_test
42 | , [ {users, [{<<"user1">>, <<"pwd1">>}, {<<"user2">>, <<"pwd2">>}]}
43 | ]
44 | }
45 | ].
46 |
--------------------------------------------------------------------------------
/test/utils/sr_test_utils.erl:
--------------------------------------------------------------------------------
1 | %%% @doc Test Utilities
2 | -module(sr_test_utils).
3 |
4 | -type config() :: [{atom(), term()}].
5 | -export_type([config/0]).
6 |
7 | -export([ application_processes/1
8 | , fail/1
9 | , fail/2
10 | ]).
11 | -export([ all/1
12 | , init_per_suite/1
13 | , end_per_suite/1
14 | ]).
15 | -export([ api_call/2
16 | , api_call/3
17 | , api_call/4
18 | , api_call/5
19 | ]).
20 |
21 | -spec application_processes(atom()) -> [pid()].
22 | application_processes(App) ->
23 | [Pid || {running, RunningApps} <- application:info()
24 | , {AppName, Pid} <- RunningApps
25 | , AppName =:= App
26 | ].
27 |
28 | -spec fail(_) -> no_return().
29 | fail(_) -> throw(an_expected_error).
30 |
31 | -spec fail(_, _) -> no_return().
32 | fail(_, _) -> throw(an_expected_error).
33 |
34 | -spec all(atom()) -> [atom()].
35 | all(Module) ->
36 | ExcludedFuns = [module_info, init_per_suite, end_per_suite],
37 | [F || {F, 1} <- Module:module_info(exports)] -- ExcludedFuns.
38 |
39 | -spec init_per_suite(config()) -> config().
40 | init_per_suite(Config) ->
41 | {ok, _} = application:ensure_all_started(sr_test),
42 | {ok, _} = shotgun:start(),
43 | Config.
44 |
45 | -spec end_per_suite(config()) -> config().
46 | end_per_suite(Config) ->
47 | ok = application:stop(sr_test),
48 | ok = shotgun:stop(),
49 | Config.
50 |
51 | -spec api_call(atom(), string()) -> map().
52 | api_call(Method, Uri) ->
53 | api_call(Method, Uri, #{}).
54 |
55 | -spec api_call(atom(), string(), map()) -> map().
56 | api_call(Method, Uri, Headers) ->
57 | api_call(Method, Uri, Headers, []).
58 |
59 | -spec api_call(atom(), string(), map(), map() | iodata()) -> map().
60 | api_call(Method, Uri, Headers, Body) ->
61 | api_call(Method, Uri, Headers, #{}, Body).
62 |
63 | -spec api_call(atom(), string(), map(), map(), map()|iodata()) -> map().
64 | api_call(Method, Uri, Headers, Opts, Body) when is_map(Body) ->
65 | api_call(Method, Uri, Headers, Opts, sr_json:encode(Body));
66 | api_call(Method, Uri, Headers, Opts, Body) ->
67 | {ok, Pid} = shotgun:open("localhost", 4891),
68 | FullOpts = maps:merge(#{timeout => 30000}, Opts),
69 | try
70 | case shotgun:request(Pid, Method, Uri, Headers, Body, FullOpts) of
71 | {ok, Response} -> Response;
72 | {error, Reason} -> {error, Reason}
73 | end
74 | after
75 | shotgun:close(Pid)
76 | end.
77 |
--------------------------------------------------------------------------------