├── .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 | [![Build Status](https://travis-ci.org/inaka/sumo_rest.svg?branch=master)](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 | ![Architecture](https://docs.google.com/drawings/d/1mlJTIxd7mH_48hcWmip_zW6rfzglbmSprpGSsfhjcsM/pub?w=367&h=288) 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 | --------------------------------------------------------------------------------