├── .awconfig ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── sys.config ├── elvis.config ├── rebar.config ├── rebar.lock ├── relx.config ├── src ├── canillita.app.src ├── canillita.erl ├── canillita_news_handler.erl ├── canillita_newsitems.erl ├── canillita_newsitems_events_handler.erl ├── canillita_newsitems_handler.erl ├── canillita_newsitems_repo.erl ├── canillita_newspapers.erl ├── canillita_newspapers_handler.erl ├── canillita_newspapers_repo.erl ├── canillita_single_newsitem_handler.erl └── canillita_single_newspaper_handler.erl └── test ├── canillita.coverspec ├── canillita_SUITE.erl ├── canillita_meta_SUITE.erl ├── canillita_news_SUITE.erl ├── canillita_newsitems_SUITE.erl ├── canillita_newspapers_SUITE.erl ├── canillita_test_utils.erl └── test.config /.awconfig: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.crashdump 2 | compile_commands.json 3 | _build/ 4 | canillita.d 5 | relx 6 | _rel/ 7 | .erlang.mk/ 8 | db 9 | Mnesia* 10 | ebin 11 | deps 12 | doc 13 | examples/blog/log 14 | .eunit 15 | *.o 16 | *.beam 17 | *.plt 18 | .rebar 19 | log*/ 20 | erl_crash.dump 21 | .erlang.mk.* 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [2.0.1](https://github.com/inaka/canillita/tree/2.0.1) (2016-05-27) 4 | [Full Changelog](https://github.com/inaka/canillita/compare/2.0.0...2.0.1) 5 | 6 | **Closed issues:** 7 | 8 | - Update sumo\_rest dependency to 0.1.2 version [\#28](https://github.com/inaka/canillita/issues/28) 9 | - Error when POSTing to canillita URL [\#27](https://github.com/inaka/canillita/issues/27) 10 | - Fulfil the Open-Source Checklist [\#15](https://github.com/inaka/canillita/issues/15) 11 | 12 | **Merged pull requests:** 13 | 14 | - Some quick fixes [\#34](https://github.com/inaka/canillita/pull/34) ([elbrujohalcon](https://github.com/elbrujohalcon)) 15 | - Added a line to let the user know they can donwload the compiled app … [\#33](https://github.com/inaka/canillita/pull/33) ([agerace](https://github.com/agerace)) 16 | - Move mixer to the main DEPS because it's actually used in the project… [\#32](https://github.com/inaka/canillita/pull/32) ([elbrujohalcon](https://github.com/elbrujohalcon)) 17 | - Update erlang.mk and deps [\#31](https://github.com/inaka/canillita/pull/31) ([elbrujohalcon](https://github.com/elbrujohalcon)) 18 | - \[Fix \#28\] Update sumo\_rest dependency to 0.1.2 [\#29](https://github.com/inaka/canillita/pull/29) ([harenson](https://github.com/harenson)) 19 | 20 | ## [2.0.0](https://github.com/inaka/canillita/tree/2.0.0) (2016-01-04) 21 | [Full Changelog](https://github.com/inaka/canillita/compare/1.0...2.0.0) 22 | 23 | **Closed issues:** 24 | 25 | - Release [\#24](https://github.com/inaka/canillita/issues/24) 26 | - canillita\_newsitems\_repo:fetch/1 is exported but not used outside of the module [\#19](https://github.com/inaka/canillita/issues/19) 27 | - Wrong spec definition for canillita\_newsitems\_repo:fetch\_since/1 [\#18](https://github.com/inaka/canillita/issues/18) 28 | - Remove event dispatcher and supervisor [\#14](https://github.com/inaka/canillita/issues/14) 29 | - /news API [\#10](https://github.com/inaka/canillita/issues/10) 30 | - /newspapers/:name/news API [\#9](https://github.com/inaka/canillita/issues/9) 31 | - /newspapers API [\#8](https://github.com/inaka/canillita/issues/8) 32 | - Initial Project Setup [\#6](https://github.com/inaka/canillita/issues/6) 33 | 34 | **Merged pull requests:** 35 | 36 | - Add CanillitaV2 blog post link to README.md [\#26](https://github.com/inaka/canillita/pull/26) ([harenson](https://github.com/harenson)) 37 | - \[Fix \#24\] Add release [\#25](https://github.com/inaka/canillita/pull/25) ([harenson](https://github.com/harenson)) 38 | - Update README.md [\#23](https://github.com/inaka/canillita/pull/23) ([harenson](https://github.com/harenson)) 39 | - \[Fix \#19\] Remove canillita\_newsitems\_repo:fetch/1 from export directive … [\#22](https://github.com/inaka/canillita/pull/22) ([harenson](https://github.com/harenson)) 40 | - \[Fix \#18\] Fix spec definition for canillita\_newsitems\_repo:fetch\_since/1 [\#21](https://github.com/inaka/canillita/pull/21) ([harenson](https://github.com/harenson)) 41 | - Improve code comments [\#20](https://github.com/inaka/canillita/pull/20) ([harenson](https://github.com/harenson)) 42 | - \[Fix \#14\] Remove event dispatcher and supervisor [\#17](https://github.com/inaka/canillita/pull/17) ([harenson](https://github.com/harenson)) 43 | - \[Fix \#10\] Add /news API [\#16](https://github.com/inaka/canillita/pull/16) ([harenson](https://github.com/harenson)) 44 | - \[Fix \#9\] Add /newspapers/:name/news API [\#13](https://github.com/inaka/canillita/pull/13) ([harenson](https://github.com/harenson)) 45 | - \[Fix \#8\] Add /newspapers API [\#12](https://github.com/inaka/canillita/pull/12) ([harenson](https://github.com/harenson)) 46 | - \[Fix \#6\] Initial project setup [\#11](https://github.com/inaka/canillita/pull/11) ([harenson](https://github.com/harenson)) 47 | - Add Reference to v1 [\#7](https://github.com/inaka/canillita/pull/7) ([elbrujohalcon](https://github.com/elbrujohalcon)) 48 | 49 | ## [1.0](https://github.com/inaka/canillita/tree/1.0) (2015-09-11) 50 | **Closed issues:** 51 | 52 | - release handling [\#2](https://github.com/inaka/canillita/issues/2) 53 | - Cann't make [\#1](https://github.com/inaka/canillita/issues/1) 54 | 55 | **Merged pull requests:** 56 | 57 | - Updated license [\#5](https://github.com/inaka/canillita/pull/5) ([spike886](https://github.com/spike886)) 58 | - Update LICENSE [\#4](https://github.com/inaka/canillita/pull/4) ([andresinaka](https://github.com/andresinaka)) 59 | - Outdated dependencies syntax [\#3](https://github.com/inaka/canillita/pull/3) ([GuidoRumi](https://github.com/GuidoRumi)) 60 | 61 | 62 | 63 | \* *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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #canillita 2 | 3 | Simple Paperboy-themed PubSub on top of REST+SSE. 4 | 5 | You can find a blog post about this app in our [Inaka's Blog](http://inaka.net/blog/2016/01/04/canillita-your-first-erlang-web-server-V2/) :) 6 | 7 | ## :warning: DISCLAIMER :warning: 8 | This is canillita **v2**, if you're looking for **v1** switch to branch [v1](https://github.com/inaka/canillita/tree/v1) or tag [1.0](https://github.com/inaka/canillita/tree/1.0) :) 9 | 10 | ### Introduction 11 | **Canillita** provides a RESTful API that allow us to manage `news` by `newspaper` and every time a `news item` is published, the listeners get a notification with it. 12 | 13 | ### Tests 14 | You can run the tests executing `rebar3 do dialyzer, ct` in the root folder of the app. 15 | 16 | ### The Server 17 | In order to run this application you need to execute the following commands: 18 | 19 | ``` 20 | # Create release 21 | $ rebar3 release 22 | # Run server 23 | $ _build/default/rel/canillita/bin/canillita console 24 | ``` 25 | After that you're just good to go. 26 | 27 | Or you can download a compiled app from the latest release. 28 | 29 | ### RESTful API 30 | After starting the server you can go to `http://localhost:4892/api-docs` and it will give you a nice and practical documentation for Canillita thanks to `cowboy-swagger` and `cowboy-trails`. 31 | 32 | --- 33 | 34 | ## Contact Us 35 | If you find any **bugs** or have a **problem** while using this project, please [open an issue](https://github.com/inaka/canillita/issues/new) in this repo (or a pull request :\)). 36 | 37 | And you can check all of our open-source projects at [inaka.github.io](http://inaka.github.io) 38 | -------------------------------------------------------------------------------- /config/sys.config: -------------------------------------------------------------------------------- 1 | [ { cowboy_swagger 2 | , [ { global_spec 3 | , #{ swagger => "2.0" 4 | , info => #{title => "Canillita Test API"} 5 | } 6 | } 7 | ] 8 | } 9 | , { mnesia 10 | , [{debug, true}] 11 | } 12 | , { sumo_db 13 | , [ {wpool_opts, [{overrun_warning, 100}]} 14 | , {log_queries, true} 15 | , {query_timeout, 30000} 16 | , {storage_backends, []} 17 | , {stores, [{canillita_store_mnesia, sumo_store_mnesia, [{workers, 10}]}]} 18 | , { docs 19 | , [ {canillita_newspapers, canillita_store_mnesia, #{module => canillita_newspapers}} 20 | , {canillita_newsitems, canillita_store_mnesia, #{module => canillita_newsitems}} 21 | ] 22 | } 23 | , {events, [{canillita_newsitems, canillita_newsitems_events_manager}]} 24 | ] 25 | } 26 | , {canillita, []} 27 | , { sasl 28 | , [ {sasl_error_logger, tty} 29 | , {errlog_type, all} 30 | ] 31 | } 32 | ]. 33 | -------------------------------------------------------------------------------- /elvis.config: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | elvis, 4 | [ 5 | {config, 6 | [#{dirs => ["src", "src/", "test"], 7 | filter => "*.erl", 8 | ruleset => erl_files, 9 | rules => 10 | [ { elvis_style 11 | , invalid_dynamic_call 12 | , #{ignore => [canillita_test_utils]} 13 | } 14 | ] 15 | }, 16 | #{dirs => ["."], 17 | filter => "Makefile", 18 | ruleset => makefiles 19 | }, 20 | #{dirs => ["."], 21 | filter => "elvis.config", 22 | ruleset => elvis_config 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | ]. 29 | 30 | -------------------------------------------------------------------------------- /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 | {parse_transform, lager_transform} 20 | ]}. 21 | 22 | %% == Dependencies == 23 | 24 | {deps, [ 25 | {lager, "3.4.2"}, 26 | {sumo_rest, "0.3.4"}, 27 | {mixer, "0.1.5", {pkg, inaka_mixer}}, 28 | {lasse, "1.1.0"}, 29 | {katana, "0.4.0"}, 30 | {cowboy_swagger, "1.2.2"}, 31 | {sumo_db, "0.7.1"}, 32 | {trails, "0.2.1"}, 33 | {cowboy, "1.1.2"}, 34 | {eper, "~> 0.94"} 35 | ]}. 36 | 37 | %% == Profiles == 38 | 39 | {profiles, [ 40 | {test, [ 41 | {deps, [ 42 | {katana_test, "0.1.1"}, 43 | {shotgun, "0.3.0"} 44 | ]} 45 | ]} 46 | ]}. 47 | 48 | %% == Common Test == 49 | 50 | {ct_compile_opts, [ 51 | warn_unused_vars, 52 | warn_export_all, 53 | warn_shadow_vars, 54 | warn_unused_import, 55 | warn_unused_function, 56 | warn_bif_clash, 57 | warn_unused_record, 58 | warn_deprecated_function, 59 | warn_obsolete_guard, 60 | strict_validation, 61 | warn_export_vars, 62 | warn_exported_vars, 63 | warn_missing_spec, 64 | warn_untyped_record, 65 | debug_info, 66 | {parse_transform, lager_transform} 67 | ]}. 68 | 69 | {ct_opts, [ 70 | {sys_config, ["test/test.config"]} 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 | %% == Dialyzer == 91 | 92 | {dialyzer, [ 93 | {warnings, [ race_conditions 94 | , no_return 95 | , unmatched_returns 96 | , error_handling 97 | , unknown 98 | ]}, 99 | {plt_apps, all_deps}, 100 | {plt_extra_apps, [erts, kernel, stdlib]}, 101 | {plt_location, local}, 102 | {base_plt_apps, [stdlib, kernel]}, 103 | {base_plt_location, global} 104 | ]}. 105 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.1.0", 2 | [{<<"aleppo">>,{pkg,<<"inaka_aleppo">>,<<"1.0.0">>},3}, 3 | {<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.1.2">>},0}, 4 | {<<"cowboy_swagger">>,{pkg,<<"cowboy_swagger">>,<<"1.2.2">>},0}, 5 | {<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},1}, 6 | {<<"elvis">>,{pkg,<<"elvis_core">>,<<"0.3.5">>},1}, 7 | {<<"eper">>,{pkg,<<"eper">>,<<"0.94.0">>},0}, 8 | {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, 9 | {<<"iso8601">>,{pkg,<<"inaka_iso8601">>,<<"1.1.2">>},1}, 10 | {<<"jiffy">>,{pkg,<<"jiffy">>,<<"0.14.7">>},1}, 11 | {<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.1">>},1}, 12 | {<<"katana">>,{pkg,<<"katana">>,<<"0.4.0">>},0}, 13 | {<<"katana_code">>,{pkg,<<"katana_code">>,<<"0.1.0">>},2}, 14 | {<<"lager">>,{pkg,<<"lager">>,<<"3.4.2">>},0}, 15 | {<<"lasse">>,{pkg,<<"lasse">>,<<"1.1.0">>},0}, 16 | {<<"mixer">>,{pkg,<<"inaka_mixer">>,<<"0.1.5">>},0}, 17 | {<<"quickrand">>,{pkg,<<"quickrand">>,<<"1.5.4">>},2}, 18 | {<<"ranch">>,{pkg,<<"ranch">>,<<"1.3.2">>},1}, 19 | {<<"sumo_db">>,{pkg,<<"sumo_db">>,<<"0.7.1">>},0}, 20 | {<<"sumo_rest">>,{pkg,<<"sumo_rest">>,<<"0.3.4">>},0}, 21 | {<<"trails">>,{pkg,<<"trails">>,<<"0.2.1">>},0}, 22 | {<<"uuid">>,{pkg,<<"uuid_erl">>,<<"1.5.2-rc1">>},1}, 23 | {<<"worker_pool">>,{pkg,<<"worker_pool">>,<<"2.0.1">>},1}, 24 | {<<"zipper">>,{pkg,<<"zipper">>,<<"1.0.1">>},2}]}. 25 | [ 26 | {pkg_hash,[ 27 | {<<"aleppo">>, <<"8DB14CF16BB8C263C14FF4C3F69F64D7C849D40888944F4204D2CA74F1114CEB">>}, 28 | {<<"cowboy">>, <<"61AC29EA970389A88ECA5A65601460162D370A70018AFE6F949A29DCA91F3BB0">>}, 29 | {<<"cowboy_swagger">>, <<"758F550A5C7D781BA20A1A58B57595BD0A42742208357F31C902BF97D359DBE9">>}, 30 | {<<"cowlib">>, <<"9D769A1D062C9C3AC753096F868CA121E2730B9A377DE23DEC0F7E08B1DF84EE">>}, 31 | {<<"elvis">>, <<"9C6DE2DA5317081D12512CA34EC9CAC858D3A169E6882E86BFAC97FA47962C4D">>}, 32 | {<<"eper">>, <<"F5FB2DAA0DF8878748E1C598428EDA942A173E5121FF35C1D632129B84593A3A">>}, 33 | {<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>}, 34 | {<<"iso8601">>, <<"6D84BBA9641FA39802E6B53C57E0B61B2F61BF8E81C112356B57BE8DA31DE771">>}, 35 | {<<"jiffy">>, <<"9F33B893EDD6041CEAE03BC1E50B412E858CC80B46F3D7535A7A9940A79A1C37">>}, 36 | {<<"jsx">>, <<"1453B4EB3615ACB3E2CD0A105D27E6761E2ED2E501AC0B390F5BBEC497669846">>}, 37 | {<<"katana">>, <<"8A26A45D4F13367AD70672F77EE6B9E4D3639750C55F1AE0F85A205A70D26396">>}, 38 | {<<"katana_code">>, <<"C34F3926A258D6BEACD8D21F140F3D47D175501936431C460B144339D5271A0B">>}, 39 | {<<"lager">>, <<"150B9A17B23AE6D3265CC10DC360747621CF217B7A22B8CDDF03B2909DBF7AA5">>}, 40 | {<<"lasse">>, <<"B1D8B9A528C5DF4423256E52FEE9B8F9A4A48096BB384FB5D4A7E483EB8DCACB">>}, 41 | {<<"mixer">>, <<"754630C0E60221B23E4D83ADF6E789A8B283855E23F9391EEC40980E6E800486">>}, 42 | {<<"quickrand">>, <<"47ADD4755CC5F209CBEFFD6F47C84061196CD7FAD99FD8FD12418EB0D06B939D">>}, 43 | {<<"ranch">>, <<"E4965A144DC9FBE70E5C077C65E73C57165416A901BD02EA899CFD95AA890986">>}, 44 | {<<"sumo_db">>, <<"443587FEEE9E6532172039B9C44B38829CB06225ECD10EE05B62CFF14FF570BD">>}, 45 | {<<"sumo_rest">>, <<"A2F6B1DE84EBD06AECA94CEA22AD9146E6E861F9726C2873C9ACB6C9A400AE55">>}, 46 | {<<"trails">>, <<"6769F7A2B777EC9CEAF07F22C23E5F3BE6A7F07E9A11B44725B102A832334D46">>}, 47 | {<<"uuid">>, <<"D4022AB3F4F1A28E86EA15D4075CB0C57EC908D8AF1CA2E8AF28AA815EF93C3A">>}, 48 | {<<"worker_pool">>, <<"B90273074898FA89434317991E00884DBBAFFAB5BFD964A7586317CD16FB18D4">>}, 49 | {<<"zipper">>, <<"3CCB4F14B97C06B2749B93D8B6C204A1ECB6FAFC6050CACC3B93B9870C05952A">>}]} 50 | ]. 51 | -------------------------------------------------------------------------------- /relx.config: -------------------------------------------------------------------------------- 1 | {release, {canillita, "2.0.1"}, [canillita, eper]}. 2 | {extended_start_script, true}. 3 | {sys_config, "./config/sys.config"}. 4 | {include_src, false}. 5 | -------------------------------------------------------------------------------- /src/canillita.app.src: -------------------------------------------------------------------------------- 1 | {application, canillita, [ 2 | {description, "Canillita - your first Erlang server!"}, 3 | {vsn, "2.0.1"}, 4 | {modules, []}, 5 | {registered, []}, 6 | {applications, [ 7 | kernel, 8 | stdlib, 9 | mnesia, 10 | sasl, 11 | sumo_rest, 12 | lasse 13 | ]}, 14 | {mod, {canillita, []}}, 15 | {env, []}, 16 | {start_phases , [ 17 | {create_schema, []}, 18 | {start_cowboy_listeners, []}, 19 | {start_canillita_events_management, []} 20 | ]} 21 | ]}. 22 | -------------------------------------------------------------------------------- /src/canillita.erl: -------------------------------------------------------------------------------- 1 | -module(canillita). 2 | -behaviour(application). 3 | 4 | -export([start/0, start/2, start_phase/3]). 5 | -export([stop/0, stop/1]). 6 | 7 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 8 | %% ADMIN API 9 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 10 | 11 | %% @doc Starts the Application 12 | -spec start() -> {ok, [atom()]} | {error, term()}. 13 | start() -> {ok, _} = application:ensure_all_started(canillita). 14 | 15 | %% @doc Stops the Application 16 | -spec stop() -> ok | {error, term()}. 17 | stop() -> ok = application:stop(canillita). 18 | 19 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 20 | %% BEHAVIOUR CALLBACKS 21 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 22 | 23 | -spec start(Type::application:start_type(), Args::any()) -> {ok, pid()}. 24 | start(_Type, _Args) -> {ok, self()}. 25 | 26 | -spec stop(State::[]) -> ok. 27 | stop(_State) -> 28 | gen_event:delete_handler( canillita_newsitems_events_manager 29 | , canillita_newsitems_events_handler 30 | , [] 31 | ), 32 | ok. 33 | 34 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 35 | %% START PHASES 36 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 37 | 38 | %% @private 39 | -spec start_phase(atom(), StartType::application:start_type(), []) -> 40 | ok | {error, _}. 41 | start_phase(create_schema, _StartType, []) -> 42 | _ = application:stop(mnesia), 43 | Node = node(), 44 | case mnesia:create_schema([Node]) of 45 | ok -> ok; 46 | {error, {Node, {already_exists, Node}}} -> ok 47 | end, 48 | {ok, _} = application:ensure_all_started(mnesia), 49 | % Create persistency schema 50 | sumo:create_schema(); 51 | start_phase(start_cowboy_listeners, _StartType, []) -> 52 | Handlers = 53 | [ canillita_newspapers_handler 54 | , canillita_single_newspaper_handler 55 | , canillita_newsitems_handler 56 | , canillita_single_newsitem_handler 57 | , canillita_news_handler 58 | , cowboy_swagger_handler 59 | ], 60 | % Get the trails for each handler 61 | Routes = trails:trails(Handlers), 62 | % Store them so Cowboy is able to get them 63 | trails:store(Routes), 64 | % Set server routes 65 | Dispatch = trails:single_host_compile(Routes), 66 | % Set the options for the TCP layer 67 | TransOpts = [{port, 4892}], 68 | % Set the options for the HTTP layer 69 | ProtoOpts = [{env, [{dispatch, Dispatch}, {compress, true}]}], 70 | % Start Cowboy HTTP server 71 | case cowboy:start_http(canillita_server, 1, TransOpts, ProtoOpts) of 72 | {ok, _} -> ok; 73 | {error, {already_started, _}} -> ok 74 | end; 75 | start_phase(start_canillita_events_management, _StartType, []) -> 76 | % Set the handler for processing SumoDB events 77 | ok = gen_event:add_handler( canillita_newsitems_events_manager 78 | , canillita_newsitems_events_handler 79 | , [] 80 | ), 81 | % Create pg2 group to hold news listeners 82 | pg2:create(canillita_listeners). 83 | -------------------------------------------------------------------------------- /src/canillita_news_handler.erl: -------------------------------------------------------------------------------- 1 | %%% @doc GET /news API handler 2 | -module(canillita_news_handler). 3 | 4 | -behaviour(trails_handler). 5 | -behaviour(lasse_handler). 6 | 7 | %% trails_handler behaviour callback 8 | -export([trails/0]). 9 | 10 | %% API 11 | -export([notify/1]). 12 | 13 | %% lasse_handler behaviour callbacks 14 | -export([ init/3 15 | , handle_notify/2 16 | , handle_info/2 17 | , handle_error/3 18 | , terminate/3 19 | ]). 20 | 21 | -type options() :: #{path => string()}. 22 | -type last_event_id() :: binary() | undefined. 23 | -type state() :: #{}. 24 | 25 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 26 | %% trails_handler callback 27 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 28 | 29 | -spec trails() -> trails:trails(). 30 | trails() -> 31 | Metadata = 32 | #{ get => 33 | #{ tags => ["news"] 34 | , summary => 35 | "WARNING: Do not try to use this endpoint from this page." 36 | " Swagger doesn't understand SSE" 37 | , description => 38 | "Opens an [SSE] (http://www.w3.org/TR/eventsource/)" 39 | " connection to retrieve news updates" 40 | , externalDocs => 41 | #{ description => "RFC" 42 | , url => "http://www.w3.org/TR/eventsource/" 43 | } 44 | , produces => ["application/json"] 45 | } 46 | }, 47 | Path = "/news", 48 | Options = #{module => ?MODULE, init_args => #{path => Path}}, 49 | [trails:trail(Path, lasse_handler, Options, Metadata)]. 50 | 51 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 52 | %% API 53 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 54 | 55 | %% @doc sends an event to all the listeners 56 | -spec notify(Event::canillita_newsitems:news_item()) -> ok. 57 | notify(Event) -> 58 | lists:foreach( 59 | fun(Listener) -> 60 | lasse_handler:notify(Listener, Event) 61 | end, 62 | pg2:get_members(canillita_listeners) 63 | ). 64 | 65 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 66 | %% lasse_handler callbacks 67 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 68 | 69 | %% @doc Will be called upon initialization of the handler, receiving the 70 | %% value of the "last-event-id" header if there is one or 71 | %% `undefined` otherwise. 72 | -spec init( InitArgs::options() 73 | , LastEventId::last_event_id() 74 | , Req::cowboy_req:req() 75 | ) -> 76 | {ok, cowboy_req:req(), [lasse_handler:event()], state()}. 77 | init(_InitArgs, LastEventId, Req) -> 78 | Req1 = sr_entities_handler:announce_req(Req, #{}), 79 | NewsItems = canillita_newsitems_repo:fetch_since(LastEventId), 80 | News = [canillita_newsitems:to_sse(NewsItem) || NewsItem <- NewsItems], 81 | ok = pg2:join(canillita_listeners, self()), 82 | {ok, Req1, News, #{}}. 83 | 84 | %% @doc Receives and processes in-band messages sent through the 85 | %% lasse_handler:notify/2 function. 86 | -spec handle_notify( NewsItem::canillita_newsitems:news_item() 87 | , State::state() 88 | ) -> {send, lasse_handler:event(), state()}. 89 | handle_notify(NewsItem, State) -> 90 | Event = canillita_newsitems:to_sse(NewsItem), 91 | {send, Event, State}. 92 | 93 | %% @doc Receives and processes out-of-band messages sent directly to the 94 | %% handler's process. 95 | -spec handle_info(Info::any(), State::state()) -> lasse_handler:result(). 96 | handle_info(Info, State) -> 97 | error_logger:info_msg("~p received at ~p", [Info, State]), 98 | {nosend, State}. 99 | 100 | %% @doc If there's a problem while sending a chunk to the client, this 101 | %% function will be called after which the handler will terminate. 102 | -spec handle_error( Event::lasse_handler:event() 103 | , Error::term() 104 | , State::state() 105 | ) -> state(). 106 | handle_error(Event, Error, State) -> 107 | error_logger:warning_msg( 108 | "Couldn't send ~p in ~p: ~p", [Event, State, Error]), 109 | State. 110 | 111 | %% @doc This function will be called before terminating the handler, its 112 | %% return value is ignored. 113 | -spec terminate(Reason::any(), Req::cowboy_req:req(), State::state()) -> ok. 114 | terminate(Reason, _Req, _State) -> 115 | error_logger:info_msg("Terminating news: ~p", [Reason]), 116 | ok. 117 | -------------------------------------------------------------------------------- /src/canillita_newsitems.erl: -------------------------------------------------------------------------------- 1 | %%% @doc NewsItem Model 2 | -module(canillita_newsitems). 3 | 4 | -behaviour(sumo_doc). 5 | -behaviour(sumo_rest_doc). 6 | 7 | -type id() :: binary(). 8 | -type newspaper_name() :: canillita_newspapers:name(). 9 | -type title() :: binary(). 10 | -type body() :: binary(). 11 | 12 | -opaque news_item() :: 13 | #{ id => id() | undefined 14 | , newspaper_name => newspaper_name() 15 | , title => title() 16 | , body => body() 17 | , created_at => calendar:datetime() 18 | }. 19 | 20 | -export_type( 21 | [ id/0 22 | , newspaper_name/0 23 | , title/0 24 | , body/0 25 | , news_item/0 26 | ]). 27 | 28 | %% sumo_doc behaviour callbacks 29 | -export( 30 | [ sumo_schema/0 31 | , sumo_sleep/1 32 | , sumo_wakeup/1 33 | ]). 34 | 35 | %% sumo_rest_doc behaviour callbacks 36 | -export( 37 | [ to_json/1 38 | , from_json/1 39 | , from_json/2 40 | , update/2 41 | , location/2 42 | ]). 43 | 44 | %% Public API 45 | -export([to_sse/1]). 46 | 47 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 48 | %% sumo_doc behaviour callbacks 49 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 50 | 51 | -spec sumo_schema() -> sumo:schema(). 52 | sumo_schema() -> 53 | sumo:new_schema( 54 | ?MODULE, 55 | [ sumo:new_field(id, binary, [id, unique]) 56 | , sumo:new_field(newspaper_name, string, [not_null]) 57 | , sumo:new_field(title, string, [not_null]) 58 | , sumo:new_field(body, binary, [not_null]) 59 | , sumo:new_field(created_at, datetime, [not_null]) 60 | ]). 61 | 62 | %% @doc Convert a newspaper from its system representation to sumo's 63 | %% internal one. 64 | -spec sumo_sleep(NewsItem::news_item()) -> sumo:model(). 65 | sumo_sleep(NewsItem) -> NewsItem. 66 | 67 | %% @doc Convert a newspaper from sumo's internal representation to its 68 | %% system one. 69 | -spec sumo_wakeup(NewsItem::sumo:doc()) -> news_item(). 70 | sumo_wakeup(NewsItem) -> NewsItem. 71 | 72 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 73 | %% sumo_rest_doc behaviour callbacks 74 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 75 | 76 | %% @doc Convert a newspaper from its system representation to json. 77 | -spec to_json(NewsItem::news_item()) -> sr_json:json(). 78 | to_json(NewsItem) -> 79 | #{ id => sr_json:encode_null(maps:get(id, NewsItem)) 80 | , newspaper_name => maps:get(newspaper_name, NewsItem) 81 | , title => maps:get(title, NewsItem) 82 | , body => maps:get(body, NewsItem) 83 | , created_at => sr_json:encode_date(maps:get(created_at, NewsItem)) 84 | }. 85 | 86 | %% @doc Convert a newspaper from json to its system representation. 87 | -spec from_json(NewspaperName::newspaper_name(), sumo_rest_doc:json()) -> 88 | {ok, news_item()} | {error, iodata()}. 89 | from_json(NewspaperName, Json) -> 90 | from_json(Json#{<<"newspaper_name">> => NewspaperName}). 91 | 92 | -spec from_json(Json::sumo_rest_doc:json()) -> 93 | {ok, news_item()} | {error, iodata()}. 94 | from_json(Json) -> 95 | Now = sr_json:encode_date(calendar:universal_time()), 96 | try 97 | { ok 98 | , #{ id => sr_json:decode_null(maps:get(<<"id">>, Json, null)) 99 | , newspaper_name => maps:get(<<"newspaper_name">>, Json) 100 | , title => maps:get(<<"title">>, Json) 101 | , body => maps:get(<<"body">>, Json) 102 | , created_at => 103 | sr_json:decode_date(maps:get(<<"created_at">>, Json, Now)) 104 | } 105 | } 106 | catch 107 | _:{badkey, Key} -> {error, <<"missing field: ", Key/binary>>} 108 | end. 109 | 110 | -spec update(NewsItem::news_item(), Json::sumo_rest_doc:json()) -> 111 | {ok, news_item()}. 112 | update(NewsItem, _Json) -> {ok, NewsItem}. 113 | 114 | %% @doc Specify the URL that identifies a NewsItem. 115 | -spec location(NewsItem::news_item(), Path::sumo_rest_doc:path()) -> iodata(). 116 | location(#{id := NewsId, newspaper_name := NewspaperName}, _Path) -> 117 | iolist_to_binary(["/newspapers/", NewspaperName, "/news/", NewsId]). 118 | 119 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 120 | %% Public API 121 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 122 | 123 | %% @doc Convert a newspaper from its system representation to SSE. 124 | -spec to_sse(NewsItem::news_item()) -> lasse_handler:event(). 125 | to_sse(NewsItem) -> 126 | #{ id => maps:get(id, NewsItem) 127 | , event => maps:get(newspaper_name, NewsItem) 128 | , data => iolist_to_binary([ maps:get(title, NewsItem) 129 | , "\n" 130 | , maps:get(body, NewsItem) 131 | ]) 132 | }. 133 | -------------------------------------------------------------------------------- /src/canillita_newsitems_events_handler.erl: -------------------------------------------------------------------------------- 1 | -module(canillita_newsitems_events_handler). 2 | 3 | -behaviour(gen_event). 4 | 5 | -export([ init/1 6 | , terminate/2 7 | , handle_info/2 8 | , handle_call/2 9 | , code_change/3 10 | , handle_event/2 11 | ]). 12 | 13 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 14 | %% gen_event functions. 15 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 16 | init([]) -> 17 | {ok, []}. 18 | 19 | handle_info(_Info, State) -> 20 | {ok, State}. 21 | 22 | handle_call(_Request, State) -> 23 | {ok, not_implemented, State}. 24 | 25 | handle_event({_EventId, canillita_newsitems, persisted, [Entity]}, State) -> 26 | canillita_news_handler:notify(Entity), 27 | {ok, State}; 28 | handle_event(Event, State) -> 29 | _ = lager:info("Ignored event: ~p", [Event]), 30 | {ok, State}. 31 | 32 | code_change(_OldVsn, State, _Extra) -> 33 | {ok, State}. 34 | 35 | terminate(_Arg, _State) -> 36 | ok. 37 | -------------------------------------------------------------------------------- /src/canillita_newsitems_handler.erl: -------------------------------------------------------------------------------- 1 | %%% @doc POST /newspapers/:name/news handler. 2 | -module(canillita_newsitems_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 | , resource_exists/2 11 | , allowed_methods/2 12 | , announce_req/2 13 | , content_types_accepted/2 14 | , content_types_provided/2 15 | , handle_post/3 16 | ] 17 | }]). 18 | 19 | %% Alias 20 | -type state() :: sr_entities_handler:state(). 21 | 22 | -export([trails/0, handle_post/2]). 23 | 24 | -spec trails() -> trails:trails(). 25 | trails() -> 26 | NewspaperName = 27 | #{ name => name 28 | , in => path 29 | , description => <<"Newspaper name">> 30 | , required => true 31 | , type => string 32 | }, 33 | RequestBody = 34 | #{ name => <<"request body">> 35 | , in => body 36 | , description => <<"request body (as json)">> 37 | , required => true 38 | }, 39 | Metadata = 40 | #{ post => 41 | #{ tags => ["newsitems"] 42 | , description => "Creates a new news item" 43 | , consumes => ["application/json", "application/json; charset=utf-8"] 44 | , produces => ["application/json"] 45 | , parameters => [NewspaperName, RequestBody] 46 | } 47 | }, 48 | Path = "/newspapers/:name/news", 49 | Options = #{path => Path, model => canillita_newsitems, verbose => true}, 50 | [trails:trail(Path, ?MODULE, Options, Metadata)]. 51 | 52 | -spec handle_post(Req::cowboy_req:req(), State::state()) -> 53 | { {true, binary()} | false | halt 54 | , cowboy_req:req() 55 | , state() 56 | }. 57 | handle_post(Req, State) -> 58 | try 59 | {ok, Body, Req1} = cowboy_req:body(Req), 60 | Json = sr_json:decode(Body), 61 | {NewspaperName, _Req} = cowboy_req:binding(name, Req), 62 | % Checks that the given newspaper does exists 63 | case canillita_newspapers_repo:exists(NewspaperName) of 64 | true -> 65 | case canillita_newsitems:from_json(NewspaperName, Json) of 66 | {error, Reason} -> 67 | Req2 = cowboy_req:set_resp_body(sr_json:error(Reason), Req1), 68 | {false, Req2, State}; 69 | {ok, Entity} -> 70 | handle_post(Entity, Req1, State) 71 | end; 72 | false -> 73 | {ok, Req1} = cowboy_req:reply(404, Req), 74 | {halt, Req1, State} 75 | end 76 | catch 77 | _:badjson -> 78 | Req3 = 79 | cowboy_req:set_resp_body( 80 | sr_json:error(<<"Malformed JSON request">>), Req), 81 | {false, Req3, State} 82 | end. 83 | -------------------------------------------------------------------------------- /src/canillita_newsitems_repo.erl: -------------------------------------------------------------------------------- 1 | %%% @doc NewsItems repository 2 | -module(canillita_newsitems_repo). 3 | 4 | -export([fetch/2, fetch_since/1]). 5 | 6 | %% @doc Returns the newsitem that matches the given 7 | %% newspaper_name and id (if any). 8 | -spec fetch( NewspaperName::canillita_newspapers:name() 9 | , Id::canillita_newsitems:id() 10 | ) -> notfound | sumo_rest_doc:entity(). 11 | fetch(NewspaperName, Id) -> 12 | Conditions = [{id, Id}, {newspaper_name, NewspaperName}], 13 | sumo:find_one(canillita_newsitems, Conditions). 14 | 15 | %% @doc returns all the news after the given event-id or all the news 16 | %% if not event-id provided. 17 | -spec fetch_since(LastEventId::canillita_newsitems:id() | undefined) -> 18 | [sumo_rest_doc:entity()]. 19 | fetch_since(undefined) -> 20 | fetch_all(); 21 | fetch_since(LastEventId) -> 22 | #{created_at := CreatedAt} = fetch(LastEventId), 23 | fetch_all(CreatedAt). 24 | 25 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 26 | %% internal 27 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 28 | 29 | %% @doc returns the newsitem identified with the given id. 30 | -spec fetch(Id::canillita_newsitems:id()) -> notfound | sumo_rest_doc:entity(). 31 | fetch(Id) -> 32 | sumo:fetch(canillita_newsitems, Id). 33 | 34 | %% @doc returns all the newsitems stored so far. 35 | -spec fetch_all() -> [sumo_rest_doc:entity()]. 36 | fetch_all() -> 37 | sumo:find_all(canillita_newsitems). 38 | 39 | %% @doc returns all elements created after the given datetime. 40 | -spec fetch_all(CreatedAt::calendar:datetime()) -> [sumo_rest_doc:entity()]. 41 | fetch_all(CreatedAt) -> 42 | Conditions = [{created_at, '>', CreatedAt}], 43 | sumo:find_by(canillita_newsitems, Conditions). 44 | -------------------------------------------------------------------------------- /src/canillita_newspapers.erl: -------------------------------------------------------------------------------- 1 | %%% @doc Newspapers Model 2 | -module(canillita_newspapers). 3 | 4 | -behaviour(sumo_doc). 5 | -behaviour(sumo_rest_doc). 6 | 7 | -type name() :: binary(). 8 | -type description() :: binary(). 9 | 10 | -opaque newspaper() :: 11 | #{ name => name() 12 | , description => description() 13 | , created_at => calendar:datetime() 14 | , updated_at => calendar:datetime() 15 | }. 16 | 17 | -export_type( 18 | [ name/0 19 | , description/0 20 | , newspaper/0 21 | ]). 22 | 23 | %% sumo_doc behaviour 24 | -export( 25 | [ sumo_schema/0 26 | , sumo_sleep/1 27 | , sumo_wakeup/1 28 | ]). 29 | 30 | %% sumo_rest_doc behaviour 31 | -export( 32 | [ to_json/1 33 | , from_json/1 34 | , update/2 35 | , location/2 36 | , duplication_conditions/1 37 | ]). 38 | 39 | %% public API 40 | -export( 41 | [ new/2 42 | , name/1 43 | ]). 44 | 45 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 46 | %% sumo_doc behaviour callbacks 47 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 48 | 49 | -spec sumo_schema() -> sumo:schema(). 50 | sumo_schema() -> 51 | sumo:new_schema( 52 | ?MODULE, 53 | [ sumo:new_field(name, binary, [id, unique]) 54 | , sumo:new_field(description, string, [not_null]) 55 | , sumo:new_field(created_at, datetime, [not_null]) 56 | , sumo:new_field(updated_at, datetime, [not_null]) 57 | ]). 58 | 59 | %% @doc Convert a newspaper from its system representation to sumo's 60 | %% internal one. 61 | -spec sumo_sleep(Newspaper::newspaper()) -> sumo:model(). 62 | sumo_sleep(Newspaper) -> Newspaper. 63 | 64 | %% @doc Convert a newspaper from sumo's internal representation to its 65 | %% system one. 66 | -spec sumo_wakeup(Newspaper::sumo:doc()) -> newspaper(). 67 | sumo_wakeup(Newspaper) -> Newspaper. 68 | 69 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 70 | %% sumo_rest_doc behaviour callbacks 71 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 72 | 73 | %% @doc Convert a newspaper from its system representation to json. 74 | -spec to_json(Newspaper::newspaper()) -> sr_json:json(). 75 | to_json(Newspaper) -> 76 | #{ name => maps:get(name, Newspaper) 77 | , description => maps:get(description, Newspaper) 78 | , created_at => sr_json:encode_date(maps:get(created_at, Newspaper)) 79 | , updated_at => sr_json:encode_date(maps:get(updated_at, Newspaper)) 80 | }. 81 | 82 | %% @doc Convert a newspaper from json to its system representation. 83 | -spec from_json(Json::sumo_rest_doc:json()) -> 84 | {ok, newspaper()} | {error, iodata()}. 85 | from_json(Json) -> 86 | Now = sr_json:encode_date(calendar:universal_time()), 87 | try 88 | { ok 89 | , #{ name => maps:get(<<"name">>, Json) 90 | , description => maps:get(<<"description">>, Json) 91 | , created_at => 92 | sr_json:decode_date(maps:get(<<"created_at">>, Json, Now)) 93 | , updated_at => 94 | sr_json:decode_date(maps:get(<<"updated_at">>, Json, Now)) 95 | } 96 | } 97 | catch 98 | _: {badkey, Key} -> {error, <<"missing field: ", Key/binary>>} 99 | end. 100 | 101 | -spec update(Newspaper::newspaper(), Json::sumo_rest_doc:json()) -> 102 | {ok, newspaper()} | {error, iodata()}. 103 | update(Newspaper, Json) -> 104 | try 105 | NewDescription = maps:get(<<"description">>, Json), 106 | UpdatedNewspaper = 107 | Newspaper#{description := NewDescription, 108 | updated_at := calendar:universal_time()}, 109 | {ok, UpdatedNewspaper} 110 | catch 111 | _:{badkey, Key} -> {error, <<"missing field: ", Key/binary>>} 112 | end. 113 | 114 | %% @doc Specify the uri part that uniquely identifies a Newspaper. 115 | -spec location(Newspaper::newspaper(), Path::sumo_rest_doc:path()) -> iodata(). 116 | location(Newspaper, Path) -> iolist_to_binary([Path, $/, name(Newspaper)]). 117 | 118 | %% @doc Optional callback duplication_conditions/1 to let sumo_rest avoid 119 | %% duplicated keys (and return `422 Conflict` in that case). 120 | -spec duplication_conditions(newspaper()) -> 121 | sumo_rest_doc:duplication_conditions(). 122 | duplication_conditions(Newspaper) -> 123 | {name, name(Newspaper)}. 124 | 125 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 126 | %% public API 127 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 128 | 129 | -spec new(Name::name(), Description::description()) -> newspaper(). 130 | new(Name, Description) -> 131 | Now = calendar:universal_time(), 132 | #{ name => Name 133 | , description => Description 134 | , created_at => Now 135 | , updated_at => Now 136 | }. 137 | 138 | -spec name(Newspaper::newspaper()) -> name(). 139 | name(#{name := Name}) -> Name. 140 | -------------------------------------------------------------------------------- /src/canillita_newspapers_handler.erl: -------------------------------------------------------------------------------- 1 | %%% @doc POST|GET /newspapers handler. 2 | -module(canillita_newspapers_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 | -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 | #{ get => 31 | #{ tags => ["newspapers"] 32 | , description => "Returns the list of newspapers" 33 | , produces => ["application/json"] 34 | } 35 | , post => 36 | # { tags => ["newspapers"] 37 | , description => "Creates a new newspaper" 38 | , consumes => ["application/json", "application/json; charset=utf-8"] 39 | , produces => ["application/json"] 40 | , parameters => [RequestBody] 41 | } 42 | }, 43 | Path = "/newspapers", 44 | Options = #{path => Path, model => canillita_newspapers, verbose => true}, 45 | [trails:trail(Path, ?MODULE, Options, Metadata)]. 46 | -------------------------------------------------------------------------------- /src/canillita_newspapers_repo.erl: -------------------------------------------------------------------------------- 1 | %%% @doc Newspapers repository 2 | -module(canillita_newspapers_repo). 3 | 4 | -export([exists/1]). 5 | 6 | %% @doc Checks that there is a newspaper with the given name. 7 | -spec exists(NewspaperName::canillita_newspapers:name()) -> boolean(). 8 | exists(NewspaperName) -> 9 | notfound /= sumo:fetch(canillita_newspapers, NewspaperName). 10 | -------------------------------------------------------------------------------- /src/canillita_single_newsitem_handler.erl: -------------------------------------------------------------------------------- 1 | %%% @doc GET /newspapers/:name/news/:id handler. 2 | -module(canillita_single_newsitem_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 | , content_types_provided/2 12 | , handle_get/2 13 | ] 14 | }]). 15 | -mixin([{ sr_entities_handler, [announce_req/2] }]). 16 | 17 | %% Alias 18 | -type state() :: sr_single_entity_handler:state(). 19 | 20 | -export([ trails/0, resource_exists/2 ]). 21 | 22 | -spec trails() -> trails:trails(). 23 | trails() -> 24 | NewspaperName = 25 | #{ name => name 26 | , in => path 27 | , description => <<"Newspaper name">> 28 | , required => true 29 | , type => string 30 | }, 31 | NewsItemId = 32 | #{ name => id 33 | , in => path 34 | , descripcion => <<"News item id">> 35 | , required => true 36 | , type => string 37 | }, 38 | Metadata = 39 | #{ get => 40 | #{ tags => ["newsitems"] 41 | , description => "Return a newsitem" 42 | , produces => ["application/json"] 43 | , parameters => [NewspaperName, NewsItemId] 44 | } 45 | }, 46 | Path = "/newspapers/:name/news/:id", 47 | Options = #{path => Path, model => canillita_newsitems, verbose => true}, 48 | [trails:trail(Path, ?MODULE, Options, Metadata)]. 49 | 50 | -spec resource_exists(cowboy_req:req(), state()) -> 51 | {boolean(), cowboy_req:req(), state()}. 52 | resource_exists(Req, State) -> 53 | {NewspaperName, Req2} = cowboy_req:binding(name, Req), 54 | case canillita_newsitems_repo:fetch(NewspaperName, sr_state:id(State)) of 55 | notfound -> {false, Req2, State}; 56 | Entity -> {true, Req2, sr_state:entity(State, Entity)} 57 | end. 58 | -------------------------------------------------------------------------------- /src/canillita_single_newspaper_handler.erl: -------------------------------------------------------------------------------- 1 | %%% @doc GET|PUT|DELETE /newspapers/:id handler 2 | -module(canillita_single_newspaper_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 | 20 | -export([ trails/0 ]). 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 | Id = 31 | #{ name => id 32 | , in => path 33 | , description => <<"Newspaper key">> 34 | , required => true 35 | , type => string 36 | }, 37 | Metadata = 38 | #{ get => 39 | #{ tags => ["newspapers"] 40 | , description => "Returns a newspaper" 41 | , produces => ["application/json"] 42 | , parameters => [Id] 43 | } 44 | , put => 45 | #{ tags => ["newspapers"] 46 | , description => "Updates or creates a new newspaper" 47 | , consumes => ["application/json", "application/json; charset=utf-8"] 48 | , produces => ["application/json"] 49 | , parameters => [RequestBody, Id] 50 | } 51 | , delete => 52 | #{ tags => ["newspapers"] 53 | , description => "Deletes a newspaper" 54 | , parameters => [Id] 55 | } 56 | }, 57 | Path = "/newspapers/:id", 58 | Options = #{path => Path, model => canillita_newspapers, verbose => true}, 59 | [trails:trail(Path, ?MODULE, Options, Metadata)]. 60 | -------------------------------------------------------------------------------- /test/canillita.coverspec: -------------------------------------------------------------------------------- 1 | %% Specific modules to include in cover. 2 | { 3 | incl_mods, 4 | [ 5 | canillita 6 | , canillita_newspapers 7 | , canillita_newspapers_repo 8 | , canillita_newspapers_handler 9 | , canillita_single_newspaper_handler 10 | , canillita_newsitems 11 | , canillita_newsitems_repo 12 | , canillita_newsitems_handler 13 | , canillita_single_newsitem_handler 14 | , canillita_news_handler 15 | ] 16 | }. 17 | -------------------------------------------------------------------------------- /test/canillita_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(canillita_SUITE). 2 | 3 | -export([all/0]). 4 | -export([canillita_start_test/1, canillita_stop_test/1]). 5 | 6 | -type config() :: proplists:proplist(). 7 | 8 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 9 | %% Common Test 10 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 11 | -spec all() -> [atom()]. 12 | all() -> [canillita_start_test, canillita_stop_test]. 13 | 14 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 15 | %% Test Cases 16 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 17 | 18 | -spec canillita_start_test(Config::config()) -> 19 | config(). 20 | canillita_start_test(Config) -> 21 | {ok, _} = application:ensure_all_started(canillita), 22 | Config. 23 | 24 | -spec canillita_stop_test(Config::config()) -> 25 | config(). 26 | canillita_stop_test(Config) -> 27 | ok = application:stop(canillita), 28 | Config. 29 | -------------------------------------------------------------------------------- /test/canillita_meta_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(canillita_meta_SUITE). 2 | 3 | -include_lib("mixer/include/mixer.hrl"). 4 | -mixin([{ktn_meta_SUITE, [all/0, xref/1, dialyzer/1, elvis/1]}]). 5 | 6 | -export([init_per_suite/1, end_per_suite/1]). 7 | 8 | -type config() :: [{atom(), term()}]. 9 | 10 | -spec init_per_suite(config()) -> config(). 11 | init_per_suite(Config) -> [{application, canillita} | Config]. 12 | 13 | -spec end_per_suite(config()) -> config(). 14 | end_per_suite(Config) -> Config. 15 | -------------------------------------------------------------------------------- /test/canillita_news_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(canillita_news_SUITE). 2 | 3 | -include_lib("mixer/include/mixer.hrl"). 4 | -mixin([{ canillita_test_utils 5 | , [ init_per_suite/1 6 | , end_per_suite/1 7 | ] 8 | }]). 9 | 10 | -export([ all/0 11 | , init_per_testcase/2 12 | , end_per_testcase/2 13 | ]). 14 | 15 | -export([news_api_test/1, last_event_id_test/1]). 16 | 17 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 18 | %% common tests 19 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 20 | 21 | -spec all() -> [atom()]. 22 | all() -> canillita_test_utils:all(?MODULE). 23 | 24 | -spec init_per_testcase( TestCase::atom() 25 | , Config::canillita_test_utils:config() 26 | ) -> canillita_test_utils:config(). 27 | init_per_testcase(_TestCase, Config) -> 28 | _ = sumo:delete_all(canillita_newspapers), 29 | _ = sumo:delete_all(canillita_newsitems), 30 | Config. 31 | 32 | -spec end_per_testcase( TestCase::atom() 33 | , Config::canillita_test_utils:config() 34 | ) -> canillita_test_utils:config(). 35 | end_per_testcase(_TestCase, Config) -> 36 | Config. 37 | 38 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 39 | %% API tests 40 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 41 | 42 | -spec news_api_test(Config::canillita_test_utils:config()) -> 43 | {comment, string()}. 44 | news_api_test(_Config) -> 45 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 46 | % Every newsitem needs to be attached to an existing newspaper 47 | Newspaper = canillita_test_utils:create_newspaper( <<"newspaper1">>, 48 | <<"description1">> 49 | ), 50 | NewspaperBin = list_to_binary(Newspaper), 51 | Newspaper2 = canillita_test_utils:create_newspaper( <<"newspaper2">> 52 | , <<"description2">> 53 | ), 54 | NewspaperBin2 = list_to_binary(Newspaper2), 55 | % open SSE connection 56 | AsyncPid = canillita_test_utils:async_api_call("/news", Headers), 57 | % create a newsitem 58 | Body1 = #{<<"title">> => <<"title1">> , <<"body">> => <<"body1">>}, 59 | #{status_code := 201, body := RespBody1} = 60 | canillita_test_utils:create_newsitem(Newspaper, Headers, Body1), 61 | Id1 = maps:get(<<"id">>, sr_json:decode(RespBody1)), 62 | % check it matches the expected response 63 | ParsedEvent1 = 64 | #{ id => Id1 65 | , event => NewspaperBin 66 | , data => [<<"title1">>, <<"body1">>] 67 | }, 68 | ParsedEvent1 = wait_for_parsed_event(AsyncPid, ParsedEvent1), 69 | Id1 = maps:get(id, ParsedEvent1), 70 | 71 | % create a new newsitem 72 | Body2 = #{<<"title">> => <<"title2">> , <<"body">> => <<"body2">>}, 73 | #{status_code := 201, body := RespBody2} = 74 | canillita_test_utils:create_newsitem(Newspaper, Headers, Body2), 75 | Id2 = maps:get(<<"id">>, sr_json:decode(RespBody2)), 76 | % check it matches the expected response 77 | ParsedEvent2 = 78 | #{ id => Id2 79 | , event => NewspaperBin 80 | , data => [<<"title2">>, <<"body2">>] 81 | }, 82 | ParsedEvent2 = wait_for_parsed_event(AsyncPid, ParsedEvent2), 83 | Id2 = maps:get(id, ParsedEvent2), 84 | 85 | %create a new newsitem for a different newspaper 86 | Body3 = #{<<"title">> => <<"title3">>, <<"body">> => <<"body3">>}, 87 | #{status_code := 201, body := RespBody3} = 88 | canillita_test_utils:create_newsitem(Newspaper2, Headers, Body3), 89 | Id3 = maps:get(<<"id">>, sr_json:decode(RespBody3)), 90 | % and check it is also been notified 91 | ParsedEvent3 = 92 | #{ id => Id3 93 | , event => NewspaperBin2 94 | , data => [<<"title3">>, <<"body3">>] 95 | }, 96 | ParsedEvent3 = wait_for_parsed_event(AsyncPid, ParsedEvent3), 97 | Id3 = maps:get(id, ParsedEvent3), 98 | 99 | % close shotgun connection 100 | shotgun:close(AsyncPid), 101 | 102 | {comment, ""}. 103 | 104 | -spec last_event_id_test(Config::canillita_test_utils:config()) -> 105 | {comment, string()}. 106 | last_event_id_test(_Config) -> 107 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 108 | % Every newsitem needs to be attached to an existing newspaper 109 | Newspaper = canillita_test_utils:create_newspaper( <<"newspaper1">>, 110 | <<"description1">> 111 | ), 112 | NewspaperBin = list_to_binary(Newspaper), 113 | 114 | % create a newsitem0 115 | Body0 = #{<<"title">> => <<"title0">> , <<"body">> => <<"body0">>}, 116 | #{status_code := 201, body := _RespBody0} = 117 | canillita_test_utils:create_newsitem(Newspaper, Headers, Body0), 118 | % create a newsitem1 119 | Body1 = #{<<"title">> => <<"title1">> , <<"body">> => <<"body1">>}, 120 | #{status_code := 201, body := RespBody1} = 121 | canillita_test_utils:create_newsitem(Newspaper, Headers, Body1), 122 | Id1 = maps:get(<<"id">>, sr_json:decode(RespBody1)), 123 | 124 | % create header for getting just the events from now on 125 | LastEventIdHeaders = Headers#{<<"last-event-id">> => Id1}, 126 | % open SSE connection with last-event-id header set 127 | AsyncPid = canillita_test_utils:async_api_call("/news", LastEventIdHeaders), 128 | 129 | % create a newsitem2 130 | Body2 = #{<<"title">> => <<"title2">> , <<"body">> => <<"body2">>}, 131 | #{status_code := 201, body := RespBody2} = 132 | canillita_test_utils:create_newsitem(Newspaper, Headers, Body2), 133 | Id2 = maps:get(<<"id">>, sr_json:decode(RespBody2)), 134 | 135 | % check it matches the expected response 136 | ParsedEvent2 = 137 | #{ id => Id2 138 | , event => NewspaperBin 139 | , data => [<<"title2">>, <<"body2">>] 140 | }, 141 | % only the last newsitem must be returned 142 | ParsedEvent2 = wait_for_parsed_event(AsyncPid, ParsedEvent2), 143 | Id2 = maps:get(id, ParsedEvent2), 144 | 145 | % close SSE connection 146 | shotgun:close(AsyncPid), 147 | 148 | {comment, ""}. 149 | 150 | wait_for_parsed_event(AsyncPid, ParsedEvent) -> 151 | ktn_task:wait_for( 152 | fun() -> 153 | [{_, _, EventBin}] = shotgun:events(AsyncPid), 154 | Event = shotgun:parse_event(EventBin), 155 | #{data := AllData} = Event, 156 | Event#{data := binary:split(AllData, <<"\n">>, [global, trim])} 157 | end, ParsedEvent, 300, 10 158 | ). 159 | -------------------------------------------------------------------------------- /test/canillita_newsitems_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(canillita_newsitems_SUITE). 2 | 3 | -include_lib("mixer/include/mixer.hrl"). 4 | -mixin([{ canillita_test_utils 5 | , [ init_per_suite/1 6 | , end_per_suite/1 7 | ] 8 | }]). 9 | 10 | -export([ all/0 11 | , init_per_testcase/2 12 | , end_per_testcase/2 13 | ]). 14 | 15 | -export([ success_scenario/1 16 | , invalid_headers/1 17 | , invalid_parameters/1 18 | , not_found/1 19 | ]). 20 | 21 | 22 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 23 | %% common tests 24 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 25 | 26 | -spec all() -> [atom()]. 27 | all() -> canillita_test_utils:all(?MODULE). 28 | 29 | -spec init_per_testcase( TestCase::atom() 30 | , Config::canillita_test_utils:config() 31 | ) -> canillita_test_utils:config(). 32 | init_per_testcase(_TestCase, Config) -> 33 | _ = sumo:delete_all(canillita_newspapers), 34 | _ = sumo:delete_all(canillita_newsitems), 35 | Config. 36 | 37 | -spec end_per_testcase( TestCase::atom() 38 | , Config::canillita_test_utils:config() 39 | ) -> canillita_test_utils:config(). 40 | end_per_testcase(_TestCase, Config) -> 41 | Config. 42 | 43 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 44 | %% API tests 45 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 46 | 47 | -spec success_scenario(Config::canillita_test_utils:config()) -> 48 | {comment, string()}. 49 | success_scenario(_Config) -> 50 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 51 | % Every newsitem needs to be attached to an existing newspaper 52 | Newspaper1 = create_newspaper(<<"newspaper1">>, <<"description1">>), 53 | Newspaper1Bin = list_to_binary(Newspaper1), 54 | Newspaper2 = create_newspaper(<<"newspaper2">>, <<"description2">>), 55 | Newspaper2Bin = list_to_binary(Newspaper2), 56 | 57 | ct:comment("Create a newsitem"), 58 | #{status_code := 201, body := Body1} = 59 | canillita_test_utils:api_call( 60 | post 61 | , "/newspapers/" ++ Newspaper1 ++ "/news" 62 | , Headers 63 | , #{ <<"title">> => <<"title1">> 64 | , <<"body">> => <<"body1">> 65 | } 66 | ), 67 | #{ <<"id">> := NewsItem1Id 68 | , <<"newspaper_name">> := Newspaper1Bin 69 | , <<"title">> := <<"title1">> 70 | , <<"body">> := <<"body1">> 71 | , <<"created_at">> := _CreatedAt 72 | } = sr_json:decode(Body1), 73 | 74 | ct:comment("And we can fetch it"), 75 | NewsItem1Url = "/newspapers/" ++ Newspaper1 ++ "/news/" ++ NewsItem1Id, 76 | #{status_code := 200} = canillita_test_utils:api_call(get, NewsItem1Url), 77 | 78 | ct:comment("Create a new newsitem"), 79 | #{status_code := 201, body := Body2} = 80 | canillita_test_utils:api_call( 81 | post 82 | , "/newspapers/" ++ Newspaper2 ++ "/news" 83 | , Headers 84 | , #{ <<"title">> => <<"title2">> 85 | , <<"body">> => <<"body2">> 86 | } 87 | ), 88 | #{ <<"id">> := NewsItem2Id 89 | , <<"newspaper_name">> := Newspaper2Bin 90 | , <<"title">> := <<"title2">> 91 | , <<"body">> := <<"body2">> 92 | , <<"created_at">> := _CreatedAt 93 | } = sr_json:decode(Body2), 94 | 95 | ct:comment("And we can fetch it too"), 96 | NewsItem2Url = "/newspapers/" ++ Newspaper2 ++ "/news/" ++ NewsItem2Id, 97 | #{status_code := 200} = canillita_test_utils:api_call(get, NewsItem2Url), 98 | 99 | {comment, ""}. 100 | 101 | -spec invalid_headers(Config::canillita_test_utils:config()) -> 102 | {comment, string()}. 103 | invalid_headers(_Config) -> 104 | NoHeaders = #{}, 105 | InvalidHeaders = #{<<"content-type">> => <<"text/plain">>}, 106 | InvalidAccept = #{ <<"content-type">> => <<"application/json">> 107 | , <<"accept">> => <<"text/html">> 108 | }, 109 | Newspaper1 = create_newspaper(<<"newspaper1">>, <<"description1">>), 110 | NewsItem1Url = "/newspapers/" ++ Newspaper1 ++ "/news", 111 | ct:comment("content-type must be provided for POST"), 112 | #{status_code := 415} = 113 | canillita_test_utils:api_call(post, NewsItem1Url, NoHeaders, <<>>), 114 | 115 | ct:comment("content-type must be JSON for POST"), 116 | #{status_code := 415} = 117 | canillita_test_utils:api_call(post, NewsItem1Url, InvalidHeaders, <<>>), 118 | 119 | ct:comment("Agent must accept JSON for POST"), 120 | #{status_code := 406} = 121 | canillita_test_utils:api_call(post, NewsItem1Url, InvalidAccept, <<>>), 122 | 123 | {comment, ""}. 124 | 125 | -spec invalid_parameters(Config::canillita_test_utils:config()) -> 126 | {comment, string()}. 127 | invalid_parameters(_Config) -> 128 | Headers = #{<<"content-type">> => <<"application/json">>}, 129 | Newspaper1 = create_newspaper(<<"newspaper1">>, <<"description1">>), 130 | NewsItem1Url = "/newspapers/" ++ Newspaper1 ++ "/news", 131 | 132 | ct:comment("Empty or broken parameters are reported"), 133 | #{status_code := 400} = 134 | canillita_test_utils:api_call(post, NewsItem1Url, Headers, <<>>), 135 | #{status_code := 400} = 136 | canillita_test_utils:api_call(post, NewsItem1Url, Headers, <<"{">>), 137 | 138 | ct:comment("Missing parameters are reported"), 139 | None = #{}, 140 | #{status_code := 400} = 141 | canillita_test_utils:api_call(post, NewsItem1Url, Headers, None), 142 | 143 | NoTitle = #{body => <<"notitle">>}, 144 | #{status_code := 400} = 145 | canillita_test_utils:api_call(post, NewsItem1Url, Headers, NoTitle), 146 | 147 | NoBody = #{title => <<"nobody">>}, 148 | #{status_code := 400} = 149 | canillita_test_utils:api_call(post, NewsItem1Url, Headers, NoBody), 150 | 151 | {comment, ""}. 152 | 153 | -spec not_found(Config::canillita_test_utils:config()) -> {comment, string()}. 154 | not_found(_Config) -> 155 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 156 | NewsItem = #{title => <<"titlex">>, body => <<"bodyx">>}, 157 | ct:comment("Unable to create a newsitem with a non-existing newspaper"), 158 | #{status_code := 404} = 159 | canillita_test_utils:api_call( post 160 | , "/newspapers/non-existing/news" 161 | , Headers 162 | , NewsItem 163 | ), 164 | 165 | Newspaper1 = create_newspaper(<<"newspaper1">>, <<"description1">>), 166 | Newspaper1Bin = list_to_binary(Newspaper1), 167 | NewsItem1Url = "/newspapers/" ++ Newspaper1 ++ "/news", 168 | 169 | ct:comment("Create a newsitem"), 170 | #{status_code := 201, body := Body1} = 171 | canillita_test_utils:api_call( 172 | post 173 | , "/newspapers/" ++ Newspaper1 ++ "/news" 174 | , Headers 175 | , #{ <<"title">> => <<"titlenf1">> 176 | , <<"body">> => <<"bodynf1">> 177 | } 178 | ), 179 | #{ <<"id">> := NewsItem1Id 180 | , <<"newspaper_name">> := Newspaper1Bin 181 | , <<"title">> := <<"titlenf1">> 182 | , <<"body">> := <<"bodynf1">> 183 | , <<"created_at">> := _CreatedAt 184 | } = sr_json:decode(Body1), 185 | 186 | ct:comment("Unable to get non-existing newsitem with a valid newspaper"), 187 | #{status_code := 404} = 188 | canillita_test_utils:api_call(get, NewsItem1Url ++ "/non-existing-id"), 189 | 190 | ct:comment("Unable to get existing newsitem id with invalid newspaper"), 191 | #{status_code := 404} = 192 | canillita_test_utils:api_call( 193 | get 194 | , "/newspapers/non-existing-newspaper/news/" ++ NewsItem1Id 195 | ), 196 | 197 | {comment, ""}. 198 | 199 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 200 | %% internal 201 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 202 | 203 | -spec create_newspaper( Name::canillita_newspapers:name() 204 | , Description::canillita_newspapers:description() 205 | ) -> iodata(). 206 | create_newspaper(Name, Description) -> 207 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 208 | #{status_code := 201, body := NewspaperBody} = 209 | canillita_test_utils:api_call( 210 | post 211 | , "/newspapers" 212 | , Headers 213 | , #{ name => Name 214 | , description => Description 215 | } 216 | ), 217 | Newspaper = sr_json:decode(NewspaperBody), 218 | Name = maps:get(<<"name">>, Newspaper), 219 | binary_to_list(Name). 220 | -------------------------------------------------------------------------------- /test/canillita_newspapers_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(canillita_newspapers_SUITE). 2 | 3 | -include_lib("mixer/include/mixer.hrl"). 4 | -mixin([{ canillita_test_utils 5 | , [ init_per_suite/1 6 | , end_per_suite/1 7 | ] 8 | }]). 9 | 10 | -export([ all/0 11 | , init_per_testcase/2 12 | , end_per_testcase/2 13 | ]). 14 | -export([ success_scenario/1 15 | , duplicated_name/1 16 | , invalid_headers/1 17 | , invalid_parameters/1 18 | , not_found/1 19 | ]). 20 | 21 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 22 | %% common tests 23 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 24 | -spec all() -> [atom()]. 25 | all() -> canillita_test_utils:all(?MODULE). 26 | 27 | -spec init_per_testcase( TestCase::atom() 28 | , Config::canillita_test_utils:config() 29 | ) -> canillita_test_utils:config(). 30 | init_per_testcase(_TestCase, Config) -> 31 | _ = sumo:delete_all(canillita_newspapers), 32 | Config. 33 | 34 | -spec end_per_testcase( TestCase::atom() 35 | , Config::canillita_test_utils:config() 36 | ) -> canillita_test_utils:config(). 37 | end_per_testcase(_TestCase, Config) -> 38 | Config. 39 | 40 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 41 | %% API tests 42 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 43 | 44 | -spec success_scenario(Config::canillita_test_utils:config()) -> 45 | {comment, string()}. 46 | success_scenario(_Config) -> 47 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 48 | 49 | ct:comment("There are no newspapers"), 50 | #{status_code := 200, body := Body0} = 51 | canillita_test_utils:api_call(get, "/newspapers"), 52 | [] = sr_json:decode(Body0), 53 | 54 | ct:comment("Newspaper 1 is created"), 55 | #{status_code := 201, body := Body1} = 56 | canillita_test_utils:api_call( 57 | post 58 | , "/newspapers" 59 | , Headers 60 | , #{ name => <<"newspaper1">> 61 | , description => <<"description1">> 62 | } 63 | ), 64 | #{ <<"name">> := <<"newspaper1">> 65 | , <<"description">> := <<"description1">> 66 | , <<"created_at">> := CreatedAt 67 | , <<"updated_at">> := CreatedAt 68 | } = Newspaper1 = sr_json:decode(Body1), 69 | 70 | ct:comment("There is one newspaper now"), 71 | #{status_code := 200, body := Body2} = 72 | canillita_test_utils:api_call(get, "/newspapers"), 73 | [Newspaper1] = sr_json:decode(Body2), 74 | 75 | ct:comment("And we can fetch it (~p)", [Newspaper1]), 76 | #{status_code := 200, body := Body21} = 77 | canillita_test_utils:api_call(get, "/newspapers/newspaper1"), 78 | Newspaper1 = sr_json:decode(Body21), 79 | 80 | ct:comment("The newspaper description can be changed"), 81 | #{status_code := 200, body := Body3} = 82 | canillita_test_utils:api_call( 83 | put 84 | , "/newspapers/newspaper1" 85 | , Headers 86 | , #{ name => <<"newspaper1">> 87 | , description => <<"newdescription3">> 88 | } 89 | ), 90 | #{ <<"name">> := <<"newspaper1">> 91 | , <<"description">> := <<"newdescription3">> 92 | , <<"created_at">> := CreatedAt 93 | , <<"updated_at">> := UpdatedAt 94 | } = Newspaper3 = sr_json:decode(Body3), 95 | true = UpdatedAt >= CreatedAt, 96 | 97 | ct:comment("Still just one newspaper"), 98 | #{status_code := 200, body := Body4} = 99 | canillita_test_utils:api_call(get, "/newspapers"), 100 | [Newspaper3] = sr_json:decode(Body4), 101 | 102 | ct:comment("Newspapers can be created by PUT"), 103 | #{status_code := 201, body := Body5} = 104 | canillita_test_utils:api_call( 105 | put 106 | , "/newspapers/newspaper2" 107 | , Headers 108 | , #{ name => <<"newspaper2">> 109 | , description => <<"description2">> 110 | } 111 | ), 112 | #{ <<"name">> := <<"newspaper2">> 113 | , <<"description">> := <<"description2">> 114 | , <<"created_at">> := CreatedAt4 115 | , <<"updated_at">> := CreatedAt4 116 | } = Newspaper4 = sr_json:decode(Body5), 117 | true = CreatedAt4 >= CreatedAt, 118 | 119 | ct:comment("There are two newspapers now"), 120 | #{status_code := 200, body := Body6} = 121 | canillita_test_utils:api_call(get, "/newspapers"), 122 | [Newspaper3, Newspaper4] = sr_json:decode(Body6), 123 | 124 | ct:comment("Newspaper1 is deleted"), 125 | #{status_code := 204} = 126 | canillita_test_utils:api_call(delete, "/newspapers/newspaper1"), 127 | 128 | ct:comment("One newspaper again"), 129 | #{status_code := 200, body := Body7} = 130 | canillita_test_utils:api_call(get, "/newspapers"), 131 | [Newspaper4] = sr_json:decode(Body7), 132 | 133 | ct:comment("DELETE is not idempotent"), 134 | #{status_code := 204} = 135 | canillita_test_utils:api_call(delete, "/newspapers/newspaper2"), 136 | #{status_code := 404} = 137 | canillita_test_utils:api_call(delete, "/newspapers/newspaper2"), 138 | 139 | ct:comment("There are no newspapers"), 140 | #{status_code := 200, body := Body8} = 141 | canillita_test_utils:api_call(get, "/newspapers"), 142 | [] = sr_json:decode(Body8), 143 | 144 | {comment, ""}. 145 | 146 | -spec duplicated_name(Config::canillita_test_utils:config()) -> 147 | {comment, string()}. 148 | duplicated_name(_Config) -> 149 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 150 | Body = 151 | #{ name => <<"newspaper1">> 152 | , description => <<"description1">> 153 | }, 154 | 155 | ct:comment("Newspaper1 is created"), 156 | #{status_code := 201} = 157 | canillita_test_utils:api_call(post, "/newspapers", Headers, Body), 158 | 159 | ct:comment("Newspaper1 can't be created again"), 160 | #{status_code := 422} = 161 | canillita_test_utils:api_call(post, "/newspapers", Headers, Body), 162 | 163 | {comment, ""}. 164 | 165 | -spec invalid_headers(Config::canillita_test_utils:config()) -> 166 | {comment, string()}. 167 | invalid_headers(_Config) -> 168 | NoHeaders = #{}, 169 | InvalidHeaders = #{<<"content-type">> => <<"text/plain">>}, 170 | InvalidAccept = #{ <<"content-type">> => <<"application/json">> 171 | , <<"accept">> => <<"text/html">> 172 | }, 173 | ct:comment("content-type must be provided for POST and PUT"), 174 | #{status_code := 415} = 175 | canillita_test_utils:api_call(post, "/newspapers", NoHeaders, <<>>), 176 | #{status_code := 415} = 177 | canillita_test_utils:api_call( put 178 | , "/newspapers/noheaders" 179 | , NoHeaders 180 | , <<>> 181 | ), 182 | 183 | ct:comment("content-type must be JSON for POST and PUT"), 184 | #{status_code := 415} = 185 | canillita_test_utils:api_call(post, "/newspapers", InvalidHeaders, <<>>), 186 | #{status_code := 415} = 187 | canillita_test_utils:api_call( put 188 | , "/newspapers/badtype" 189 | , InvalidHeaders 190 | , <<>> 191 | ), 192 | 193 | ct:comment("Agent must accept JSON for POST, GET and PUT"), 194 | #{status_code := 406} = 195 | canillita_test_utils:api_call(post, "/newspapers", InvalidAccept, <<>>), 196 | #{status_code := 406} = 197 | canillita_test_utils:api_call(get, "/newspapers", InvalidAccept, <<>>), 198 | #{status_code := 406} = 199 | canillita_test_utils:api_call( put 200 | , "/newspapers/badaccept" 201 | , InvalidAccept 202 | , <<>> 203 | ), 204 | #{status_code := 406} = 205 | canillita_test_utils:api_call( get 206 | , "/newspapers/badaccept" 207 | , InvalidAccept 208 | , <<>> 209 | ), 210 | 211 | {comment, ""}. 212 | 213 | -spec invalid_parameters(Config::canillita_test_utils:config()) -> 214 | {comment, string()}. 215 | invalid_parameters(_Config) -> 216 | Headers = #{<<"content-type">> => <<"application/json">>}, 217 | _ = sumo:persist( canillita_newspapers 218 | , canillita_newspapers:new(<<"name">>, <<"description">>) 219 | ), 220 | 221 | ct:comment("Empty or broken parameters are reported"), 222 | #{status_code := 400} = 223 | canillita_test_utils:api_call(post, "/newspapers", Headers, <<>>), 224 | #{status_code := 400} = 225 | canillita_test_utils:api_call(put, "/newspapers/nobody", Headers, <<>>), 226 | #{status_code := 400} = 227 | canillita_test_utils:api_call(put, "/newspapers/name", Headers, <<>>), 228 | #{status_code := 400} = 229 | canillita_test_utils:api_call(post, "/newspapers", Headers, <<"{">>), 230 | #{status_code := 400} = 231 | canillita_test_utils:api_call(put, "/newspapers/broken", Headers, <<"{">>), 232 | #{status_code := 400} = 233 | canillita_test_utils:api_call(put, "/newspapers/name", Headers, <<"{">>), 234 | 235 | ct:comment("Missing parameters are reported"), 236 | None = #{}, 237 | #{status_code := 400} = 238 | canillita_test_utils:api_call(post, "/newspapers", Headers, None), 239 | #{status_code := 400} = 240 | canillita_test_utils:api_call(put, "/newspapers/none", Headers, None), 241 | #{status_code := 400} = 242 | canillita_test_utils:api_call(put, "/newspapers/name", Headers, None), 243 | 244 | NoDesc = #{name => <<"nodesc">>}, 245 | #{status_code := 400} = 246 | canillita_test_utils:api_call(post, "/newspapers", Headers, NoDesc), 247 | #{status_code := 400} = 248 | canillita_test_utils:api_call(put, "/newspapers/nodesc", Headers, NoDesc), 249 | #{status_code := 400} = 250 | canillita_test_utils:api_call(put, "/newspapers/name", Headers, NoDesc), 251 | 252 | {comment, ""}. 253 | 254 | -spec not_found(Config::canillita_test_utils:config()) -> {comment, string()}. 255 | not_found(_Config) -> 256 | ct:comment("Not existing newspaper is not found"), 257 | #{status_code := 404} = 258 | canillita_test_utils:api_call(get, "/newspapers/notfound"), 259 | #{status_code := 404} = 260 | canillita_test_utils:api_call(delete, "/newspapers/notfound"), 261 | 262 | {comment, ""}. 263 | -------------------------------------------------------------------------------- /test/canillita_test_utils.erl: -------------------------------------------------------------------------------- 1 | %%% @doc General Canillita test utils 2 | -module(canillita_test_utils). 3 | 4 | -export([ all/1 5 | , init_per_suite/1 6 | , end_per_suite/1 7 | ]). 8 | -export([ api_call/2 9 | , api_call/3 10 | , api_call/4 11 | ]). 12 | -export([async_api_call/2, create_newspaper/2, create_newsitem/3]). 13 | 14 | -type config() :: proplists:proplist(). 15 | -export_type([config/0]). 16 | 17 | -type method() :: atom(). 18 | -type uri() :: string(). 19 | -type headers() :: map(). 20 | -type body() :: map() | iodata(). 21 | 22 | -spec all(Module::atom()) -> [atom()]. 23 | all(Module) -> 24 | ExcludedFuns = [module_info, init_per_suite, end_per_suite, group, all], 25 | Exports = Module:module_info(exports), 26 | [F || {F, 1} <- Exports, not lists:member(F, ExcludedFuns)]. 27 | 28 | -spec init_per_suite(Config::config()) -> config(). 29 | init_per_suite(Config) -> 30 | {ok, _} = canillita:start(), 31 | {ok, _} = shotgun:start(), 32 | Config. 33 | 34 | -spec end_per_suite(Config::config()) -> config(). 35 | end_per_suite(Config) -> 36 | _ = canillita:stop(), 37 | _ = shotgun:stop(), 38 | Config. 39 | 40 | -spec api_call(Method::method(), Uri::uri()) -> map(). 41 | api_call(Method, Uri) -> 42 | api_call(Method, Uri, #{}). 43 | 44 | -spec api_call(Method::method(), Uri::uri(), Headers::headers()) -> map(). 45 | api_call(Method, Uri, Headers) -> 46 | api_call(Method, Uri, Headers, []). 47 | 48 | -spec api_call( Method::method() 49 | , Uri::uri() 50 | , Headers::headers() 51 | , Body::body() 52 | ) -> map(). 53 | api_call(Method, Uri, Headers, Body) when is_map(Body) -> 54 | api_call(Method, Uri, Headers, sr_json:encode(Body)); 55 | api_call(Method, Uri, Headers, Body) -> 56 | {ok, Pid} = shotgun:open("localhost", 4892), 57 | try 58 | Options = #{}, 59 | {ok, Response} = shotgun:request(Pid, Method, Uri, Headers, Body, Options), 60 | Response 61 | after 62 | shotgun:close(Pid) 63 | end. 64 | 65 | -spec async_api_call(Uri::uri(), Headers::headers()) -> pid(). 66 | async_api_call(Uri, Headers) -> 67 | {ok, Pid} = shotgun:open("localhost", 4892), 68 | {ok, _} = try 69 | Options = #{async => true, async_mode => sse}, 70 | {ok, _Ref} = shotgun:get(Pid, Uri, Headers, Options) 71 | catch 72 | _:Exception -> exit({error, Exception}) 73 | end, 74 | Pid. 75 | 76 | -spec create_newspaper( Name::canillita_newspapers:name() 77 | , Description::canillita_newspapers:description() 78 | ) -> iodata(). 79 | create_newspaper(Name, Description) -> 80 | Headers = #{<<"content-type">> => <<"application/json; charset=utf-8">>}, 81 | #{status_code := 201, body := NewspaperBody} = 82 | api_call( 83 | post 84 | , "/newspapers" 85 | , Headers 86 | , #{ name => Name 87 | , description => Description 88 | } 89 | ), 90 | Newspaper = sr_json:decode(NewspaperBody), 91 | Name = maps:get(<<"name">>, Newspaper), 92 | binary_to_list(Name). 93 | 94 | -spec create_newsitem( Newspaper::string() 95 | , Headers::headers() 96 | , Body::body() 97 | ) -> map(). 98 | create_newsitem(Newspaper, Headers, Body) -> 99 | api_call( 100 | post 101 | , "/newspapers/" ++ Newspaper ++ "/news" 102 | , Headers 103 | , Body 104 | ). 105 | -------------------------------------------------------------------------------- /test/test.config: -------------------------------------------------------------------------------- 1 | [ 2 | { cowboy_swagger 3 | , [ { global_spec 4 | , #{ swagger => "2.0" 5 | , info => #{title => "Canillita Test API"} 6 | } 7 | } 8 | ] 9 | } 10 | , { lager 11 | , [ { handlers 12 | , [ {lager_console_backend, error} 13 | , {lager_common_test_backend, debug} 14 | ] 15 | } 16 | ] 17 | } 18 | , { mnesia 19 | , [{debug, true}] 20 | } 21 | , { sumo_db 22 | , [ {wpool_opts, [{overrun_warning, 100}]} 23 | , {log_queries, true} 24 | , {query_timeout, 30000} 25 | , {storage_backends, []} 26 | , {stores, [{canillita_store_mnesia, sumo_store_mnesia, [{workers, 10}]}]} 27 | , { docs 28 | , [ {canillita_newspapers, canillita_store_mnesia, #{module => canillita_newspapers}} 29 | , {canillita_newsitems, canillita_store_mnesia, #{module => canillita_newsitems}} 30 | ] 31 | } 32 | , {events, [{canillita_newsitems, canillita_newsitems_events_manager}]} 33 | ] 34 | }, 35 | {canillita, []} 36 | ]. 37 | --------------------------------------------------------------------------------