├── .github └── workflows │ └── rust.yml ├── .gitignore ├── .rustfmt.toml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── docker-compose.yml ├── src ├── client.rs ├── lib.rs ├── model.rs └── services │ ├── content.rs │ ├── mod.rs │ ├── path.rs │ ├── project.rs │ ├── repository.rs │ └── watch.rs └── tests ├── content.rs ├── projects.rs ├── repo.rs ├── utils.rs └── watch.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Build 18 | run: cargo build --verbose 19 | - name: Run tests 20 | run: cargo test --lib --verbose 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | 9 | #Added by cargo 10 | /target 11 | 12 | Cargo.lock 13 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | imports_granularity = "Crate" 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [dl_oss_dev@linecorp.com](mailto:dl_oss_dev@linecorp.com). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to Rust client library for Central Dogma project 2 | 3 | First of all, thank you so much for taking your time to contribute! This project is not very different from any other open source projects you are aware of. It will be amazing if you could help us by doing any of the following: 4 | 5 | - File an issue in [the issue tracker](https://github.com/line/centraldogma-rs/issues) to report bugs and propose new features and improvements. 6 | - Ask a question by creating a new issue in [the issue tracker](https://github.com/line/centraldogma-rs/issues). 7 | - Browse [the list of previously answered questions](https://github.com/line/centraldogma-rs/issues?q=label%3Aquestion). 8 | - Contribute your work by sending [a pull request](https://github.com/line/centraldogma-rs/pulls). 9 | 10 | ### Run test suite 11 | 12 | Run local centraldogma server with docker-compose 13 | 14 | ```bash 15 | docker-compose up -d 16 | ``` 17 | 18 | Run all tests 19 | 20 | ```bash 21 | cargo test 22 | ``` 23 | 24 | Run unit test only (centraldogma server not needed) 25 | 26 | ```bash 27 | cargo test --lib 28 | ``` 29 | 30 | 31 | ### Contributor license agreement 32 | 33 | When you are sending a pull request and it's a non-trivial change beyond fixing typos, please sign [the ICLA (individual contributor license agreement)](https://cla-assistant.io/line/centraldogma-rs). 34 | Note that this ICLA covers [Central Dogma project](https://github.com/line/centraldogma) and its subprojects, which means you can contribute to [line/centraldogma](https://github.com/line/centraldogma) and [line/centraldogma-rs](https://github.com/line/centraldogma-rs) at once by signing this ICLA. 35 | Please [contact us](mailto:dl_oss_dev@linecorp.com) if you need the CCLA (corporate contributor license agreement). 36 | 37 | ### Code of conduct 38 | We expect contributors to follow [our code of conduct](https://github.com/line/centraldogma-rs/blob/master/CODE_OF_CONDUCT.md). -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "centraldogma" 3 | version = "0.1.3" 4 | authors = ["Hoang Luu "] 5 | edition = "2021" 6 | description = "CentralDogma client for Rust" 7 | license = "Apache-2.0" 8 | documentation = "https://docs.rs/centraldogma" 9 | homepage = "https://github.com/line/centraldogma-rs" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | async-trait = "0.1" 15 | anyhow = "1" 16 | fastrand = "1" 17 | form_urlencoded = "1" 18 | reqwest = { version = "0.11", features = ["json"] } 19 | serde = { version = "1", features = ["derive"] } 20 | serde_json = "1" 21 | thiserror = "1" 22 | tokio = { version = "1", features = ["full"] } 23 | url = "2" 24 | futures = "0.3" 25 | log = "0.4" 26 | 27 | [dev-dependencies] 28 | wiremock = "0.5" 29 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # centraldogma-rs 2 | 3 | Official Rust Client for [Central Dogma](https://line.github.io/centraldogma/). 4 | 5 | Full documentation is available at 6 | 7 | ## Getting started 8 | 9 | ### Installing 10 | 11 | Add `centraldogma` crate and version to Cargo.toml. 12 | 13 | ```toml 14 | centraldogma = "0.1" 15 | ``` 16 | 17 | #### Async support with tokio 18 | The client uses [`reqwest`](https://crates.io/crates/reqwest) to make HTTP calls, which internally uses 19 | the [`tokio`](https://crates.io/crates/tokio) runtime for async support. As such, you may require to take 20 | a dependency on `tokio` in order to use the client. 21 | 22 | ```toml 23 | tokio = { version = "1.2.0", features = ["full"] } 24 | ``` 25 | 26 | ### Create a client 27 | 28 | Create a new client to make API to CentralDogma using the `Client` struct. 29 | 30 | ```rust,no_run 31 | use centraldogma::Client; 32 | 33 | #[tokio::main] 34 | async fn main() { 35 | // with token 36 | let client = Client::new("http://localhost:36462", Some("token")).await.unwrap(); 37 | // without token 38 | let client = Client::new("http://localhost:36462", None).await.unwrap(); 39 | // your code ... 40 | } 41 | ``` 42 | 43 | ### Making typed API calls 44 | 45 | Typed API calls are provided behind traits: 46 | 47 | * [`ProjectService`](https://docs.rs/centraldogma/0.1.0/centraldogma/trait.ProjectService.html) 48 | * [`RepoService`](https://docs.rs/centraldogma/0.1.0/centraldogma/trait.RepoService.html) 49 | * [`ContentService`](https://docs.rs/centraldogma/0.1.0/centraldogma/trait.ContentService.html) 50 | * [`WatchService`](https://docs.rs/centraldogma/0.1.0/centraldogma/trait.WatchService.html) 51 | 52 | #### Examples 53 | 54 | ##### Get File 55 | 56 | ```rust,no_run 57 | use centraldogma::{ 58 | Client, ContentService, 59 | model::{Revision, Query}, 60 | }; 61 | 62 | #[tokio::main] 63 | async fn main() { 64 | // without token 65 | let client = Client::new("http://localhost:36462", None).await.unwrap(); 66 | 67 | let file = client 68 | .repo("project", "repository") 69 | .get_file(Revision::HEAD, &Query::of_text("/a.yml").unwrap()) 70 | .await 71 | .unwrap(); 72 | // your code ... 73 | } 74 | ``` 75 | 76 | ##### Push 77 | 78 | ```rust,no_run 79 | use centraldogma::{ 80 | Client, ContentService, 81 | model::{Revision, Change, ChangeContent, CommitMessage}, 82 | }; 83 | use serde_json; 84 | 85 | #[tokio::main] 86 | async fn main() { 87 | let client = Client::new("http://localhost:36462", None).await.unwrap(); 88 | let changes = vec![Change { 89 | path: "/a.json".to_string(), 90 | content: ChangeContent::UpsertJson(serde_json::json!({"a":"b"})), 91 | }]; 92 | let result = client 93 | .repo("foo", "bar") 94 | .push( 95 | Revision::HEAD, 96 | CommitMessage::only_summary("Add a.json"), 97 | changes, 98 | ) 99 | .await 100 | .unwrap(); 101 | } 102 | ``` 103 | 104 | ##### Watch file change 105 | 106 | ```rust,no_run 107 | use centraldogma::{Client, WatchService, model::Query}; 108 | use futures::StreamExt; 109 | 110 | #[tokio::main] 111 | async fn main() { 112 | let client = Client::new("http://localhost:36462", None).await.unwrap(); 113 | let mut stream = client 114 | .repo("foo", "bar") 115 | .watch_file_stream(&Query::identity("/a.json").unwrap()) 116 | .unwrap(); 117 | 118 | tokio::spawn(async move { 119 | while let Some(result) = stream.next().await { 120 | // your code ... 121 | } 122 | }); 123 | } 124 | ``` 125 | 126 | ## Contributing 127 | 128 | See [CONTRIBUTING.md](CONTRIBUTING.md). 129 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | centraldogma: 4 | image: line/centraldogma:latest 5 | ports: 6 | - "36462:36462" 7 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use reqwest::{header::HeaderValue, Body, Method, Request}; 4 | use thiserror::Error; 5 | use url::Url; 6 | 7 | use crate::model::Revision; 8 | 9 | const WATCH_BUFFER_TIMEOUT: Duration = Duration::from_secs(5); 10 | 11 | /// An error happen with the client. 12 | /// Errors that can occur include I/O and parsing errors, 13 | /// as well as error response from centraldogma server 14 | #[derive(Error, Debug)] 15 | pub enum Error { 16 | /// Error from HTTP Request 17 | #[error("HTTP Client error")] 18 | HttpClient(#[from] reqwest::Error), 19 | 20 | /// Error when provided invalid base_url 21 | #[allow(clippy::upper_case_acronyms)] 22 | #[error("Invalid URL")] 23 | InvalidURL(#[from] url::ParseError), 24 | 25 | /// Error when parse response json into Rust model structs 26 | #[error("Failed to parse json")] 27 | ParseError(#[from] serde_json::Error), 28 | 29 | /// Error when provided invalid parameters 30 | #[error("Invalid params: {0}")] 31 | InvalidParams(&'static str), 32 | 33 | /// Errors returned from CentralDomgma server (status code > 300) 34 | /// (HTTP StatusCode, Response string from server) 35 | #[error("Error response: [{0}] {1}")] 36 | ErrorResponse(u16, String), 37 | } 38 | 39 | /// Root client for top level APIs. 40 | /// Implements [`crate::ProjectService`] 41 | #[derive(Clone)] 42 | pub struct Client { 43 | base_url: Url, 44 | token: HeaderValue, 45 | http_client: reqwest::Client, 46 | } 47 | 48 | impl Client { 49 | /// Returns a new client from provided `base_url` and an optional 50 | /// `token` string for authentication. 51 | /// Only visible ASCII characters (32-127) are permitted as token. 52 | pub async fn new(base_url: &str, token: Option<&str>) -> Result { 53 | let url = url::Url::parse(base_url)?; 54 | let http_client = reqwest::Client::builder().user_agent("cd-rs").build()?; 55 | 56 | let mut header_value = HeaderValue::from_str(&format!( 57 | "Bearer {}", 58 | token.as_ref().unwrap_or(&"anonymous") 59 | )) 60 | .map_err(|_| Error::InvalidParams("Invalid token received"))?; 61 | header_value.set_sensitive(true); 62 | 63 | Ok(Client { 64 | base_url: url, 65 | token: header_value, 66 | http_client, 67 | }) 68 | } 69 | 70 | pub(crate) async fn request(&self, req: reqwest::Request) -> Result { 71 | Ok(self.http_client.execute(req).await?) 72 | } 73 | 74 | pub(crate) fn new_request>( 75 | &self, 76 | method: reqwest::Method, 77 | path: S, 78 | body: Option, 79 | ) -> Result { 80 | self.new_request_inner(method, path.as_ref(), body) 81 | } 82 | 83 | fn new_request_inner( 84 | &self, 85 | method: reqwest::Method, 86 | path: &str, 87 | body: Option, 88 | ) -> Result { 89 | let mut req = Request::new(method, self.base_url.join(path)?); 90 | 91 | // HeaderValue's clone is cheap as it's using Bytes underneath 92 | req.headers_mut() 93 | .insert("Authorization", self.token.clone()); 94 | 95 | if let Method::PATCH = *req.method() { 96 | req.headers_mut().insert( 97 | "Content-Type", 98 | HeaderValue::from_static("application/json-patch+json"), 99 | ); 100 | } else { 101 | req.headers_mut() 102 | .insert("Content-Type", HeaderValue::from_static("application/json")); 103 | } 104 | 105 | *req.body_mut() = body; 106 | 107 | Ok(req) 108 | } 109 | 110 | pub(crate) fn new_watch_request>( 111 | &self, 112 | method: reqwest::Method, 113 | path: S, 114 | body: Option, 115 | last_known_revision: Option, 116 | timeout: Duration, 117 | ) -> Result { 118 | let mut req = self.new_request(method, path, body)?; 119 | 120 | match last_known_revision { 121 | Some(rev) => { 122 | let val = HeaderValue::from_str(&rev.to_string()).unwrap(); 123 | req.headers_mut().insert("if-none-match", val); 124 | } 125 | None => { 126 | let val = HeaderValue::from_str(&Revision::HEAD.to_string()).unwrap(); 127 | req.headers_mut().insert("if-none-match", val); 128 | } 129 | } 130 | 131 | if timeout.as_secs() != 0 { 132 | let val = HeaderValue::from_str(&format!("wait={}", timeout.as_secs())).unwrap(); 133 | req.headers_mut().insert("prefer", val); 134 | } 135 | 136 | let req_timeout = timeout.checked_add(WATCH_BUFFER_TIMEOUT).unwrap(); 137 | req.timeout_mut().replace(req_timeout); 138 | 139 | Ok(req) 140 | } 141 | 142 | /// Creates a temporary client within a context of the specified Project. 143 | pub fn project<'a>(&'a self, project_name: &'a str) -> ProjectClient<'a> { 144 | ProjectClient { 145 | client: self, 146 | project: project_name, 147 | } 148 | } 149 | 150 | /// Creates a temporary client within a context of the specified Repository. 151 | pub fn repo<'a>(&'a self, project_name: &'a str, repo_name: &'a str) -> RepoClient<'a> { 152 | RepoClient { 153 | client: self, 154 | project: project_name, 155 | repo: repo_name, 156 | } 157 | } 158 | } 159 | 160 | /// A temporary client within context of a project. 161 | /// Created by [`Client::project()`] 162 | /// Implements [`crate::RepoService`] 163 | pub struct ProjectClient<'a> { 164 | pub(crate) client: &'a Client, 165 | pub(crate) project: &'a str, 166 | } 167 | 168 | /// A temporary client within context of a Repository. 169 | /// Created by [`Client::repo()`] 170 | /// Implements [`crate::ContentService`] and 171 | /// [`crate::WatchService`] 172 | pub struct RepoClient<'a> { 173 | pub(crate) client: &'a Client, 174 | pub(crate) project: &'a str, 175 | pub(crate) repo: &'a str, 176 | } 177 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | mod client; 3 | pub mod model; 4 | mod services; 5 | 6 | pub use client::{Client, Error, ProjectClient, RepoClient}; 7 | pub use services::{ 8 | content::ContentService, project::ProjectService, repository::RepoService, watch::WatchService, 9 | }; 10 | -------------------------------------------------------------------------------- /src/model.rs: -------------------------------------------------------------------------------- 1 | //! Data models of CentralDogma 2 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 3 | 4 | /// A revision number of a [`Commit`]. 5 | /// 6 | /// A revision number is an integer which refers to a specific point of repository history. 7 | /// When a repository is created, it starts with an initial commit whose revision is 1. 8 | /// As new commits are added, each commit gets its own revision number, 9 | /// monotonically increasing from the previous commit's revision. i.e. 1, 2, 3, ... 10 | /// 11 | /// A revision number can also be represented as a negative integer. 12 | /// When a revision number is negative, we start from -1 which refers to the latest commit in repository history, 13 | /// which is often called 'HEAD' of the repository. 14 | /// A smaller revision number refers to the older commit. 15 | /// e.g. -2 refers to the commit before the latest commit, and so on. 16 | /// 17 | /// A revision with a negative integer is called 'relative revision'. 18 | /// By contrast, a revision with a positive integer is called 'absolute revision'. 19 | #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] 20 | pub struct Revision(Option); 21 | 22 | impl Revision { 23 | pub fn as_i64(&self) -> Option { 24 | self.0 25 | } 26 | } 27 | 28 | impl std::fmt::Display for Revision { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | match self.0 { 31 | Some(n) => write!(f, "{}", n), 32 | None => write!(f, ""), 33 | } 34 | } 35 | } 36 | 37 | impl AsRef> for Revision { 38 | fn as_ref(&self) -> &Option { 39 | &self.0 40 | } 41 | } 42 | 43 | /// Create a new instance with the specified revision number. 44 | impl From for Revision { 45 | fn from(value: i64) -> Self { 46 | Self(Some(value)) 47 | } 48 | } 49 | 50 | impl Revision { 51 | /// Revision `-1`, also known as `HEAD`. 52 | pub const HEAD: Revision = Revision(Some(-1)); 53 | /// Revision `1`, also known as `INIT`. 54 | pub const INIT: Revision = Revision(Some(1)); 55 | /// Omitted revision, behavior is decided on server side, usually [`Revision::HEAD`] 56 | pub const DEFAULT: Revision = Revision(None); 57 | } 58 | 59 | /// Creator of a project or repository or commit 60 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 61 | #[serde(rename_all = "camelCase")] 62 | pub struct Author { 63 | /// Name of this author. 64 | pub name: String, 65 | /// Email of this author. 66 | pub email: String, 67 | } 68 | 69 | /// A top-level element in Central Dogma storage model. 70 | /// A project has "dogma" and "meta" repositories by default which contain project configuration 71 | /// files accessible by administrators and project owners respectively. 72 | #[derive(Debug, Serialize, Deserialize)] 73 | #[serde(rename_all = "camelCase")] 74 | pub struct Project { 75 | /// Name of this project. 76 | pub name: String, 77 | /// The author who initially created this project. 78 | pub creator: Author, 79 | /// Url of this project 80 | pub url: Option, 81 | /// When the project was created 82 | pub created_at: Option, 83 | } 84 | 85 | /// Repository information 86 | #[derive(Debug, Serialize, Deserialize)] 87 | #[serde(rename_all = "camelCase")] 88 | pub struct Repository { 89 | /// Name of this repository. 90 | pub name: String, 91 | /// The author who initially created this repository. 92 | pub creator: Author, 93 | /// Head [`Revision`] of the repository. 94 | pub head_revision: Revision, 95 | /// Url of this repository. 96 | pub url: Option, 97 | /// When the repository was created. 98 | pub created_at: Option, 99 | } 100 | 101 | /// The content of an [`Entry`] 102 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 103 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 104 | #[serde(tag = "type", content = "content")] 105 | pub enum EntryContent { 106 | /// Content as a JSON Value. 107 | Json(serde_json::Value), 108 | /// Content as a String. 109 | Text(String), 110 | /// This Entry is a directory. 111 | Directory, 112 | } 113 | 114 | /// A file or a directory in a repository. 115 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 116 | #[serde(rename_all = "camelCase")] 117 | pub struct Entry { 118 | /// Path of this entry. 119 | pub path: String, 120 | /// Content of this entry. 121 | #[serde(flatten)] 122 | pub content: EntryContent, 123 | /// Revision of this entry. 124 | pub revision: Revision, 125 | /// Url of this entry. 126 | pub url: String, 127 | /// When this entry was last modified. 128 | pub modified_at: Option, 129 | } 130 | 131 | impl Entry { 132 | pub fn entry_type(&self) -> EntryType { 133 | match self.content { 134 | EntryContent::Json(_) => EntryType::Json, 135 | EntryContent::Text(_) => EntryType::Text, 136 | EntryContent::Directory => EntryType::Directory, 137 | } 138 | } 139 | } 140 | 141 | /// The type of a [`ListEntry`] 142 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 143 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 144 | pub enum EntryType { 145 | /// A UTF-8 encoded JSON file. 146 | Json, 147 | /// A UTF-8 encoded text file. 148 | Text, 149 | /// A directory. 150 | Directory, 151 | } 152 | 153 | /// A metadata of a file or a directory in a repository. 154 | /// ListEntry has no content. 155 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 156 | #[serde(rename_all = "camelCase")] 157 | pub struct ListEntry { 158 | pub path: String, 159 | pub r#type: EntryType, 160 | } 161 | 162 | /// Type of a [`Query`] 163 | #[derive(Debug, PartialEq, Eq)] 164 | pub enum QueryType { 165 | Identity, 166 | IdentityJson, 167 | IdentityText, 168 | JsonPath(Vec), 169 | } 170 | 171 | /// A Query on a file 172 | #[derive(Debug)] 173 | pub struct Query { 174 | pub(crate) path: String, 175 | pub(crate) r#type: QueryType, 176 | } 177 | 178 | impl Query { 179 | fn normalize_path(path: &str) -> String { 180 | if path.starts_with('/') { 181 | path.to_owned() 182 | } else { 183 | format!("/{}", path) 184 | } 185 | } 186 | 187 | /// Returns a newly-created [`Query`] that retrieves the content as it is. 188 | /// Returns `None` if path is empty 189 | pub fn identity(path: &str) -> Option { 190 | if path.is_empty() { 191 | return None; 192 | } 193 | Some(Query { 194 | path: Self::normalize_path(path), 195 | r#type: QueryType::Identity, 196 | }) 197 | } 198 | 199 | /// Returns a newly-created [`Query`] that retrieves the textual content as it is. 200 | /// Returns `None` if path is empty 201 | pub fn of_text(path: &str) -> Option { 202 | if path.is_empty() { 203 | return None; 204 | } 205 | Some(Query { 206 | path: Self::normalize_path(path), 207 | r#type: QueryType::IdentityText, 208 | }) 209 | } 210 | 211 | /// Returns a newly-created [`Query`] that retrieves the JSON content as it is. 212 | /// Returns `None` if path is empty 213 | pub fn of_json(path: &str) -> Option { 214 | if path.is_empty() { 215 | return None; 216 | } 217 | Some(Query { 218 | path: Self::normalize_path(path), 219 | r#type: QueryType::IdentityJson, 220 | }) 221 | } 222 | 223 | /// Returns a newly-created [`Query`] that applies a series of 224 | /// [JSON path expressions](https://github.com/json-path/JsonPath/blob/master/README.md) 225 | /// to the content. 226 | /// Returns `None` if path is empty or does not end with `.json`. 227 | /// Returns `None` if any of the path expression provided is empty. 228 | pub fn of_json_path(path: &str, exprs: Vec) -> Option { 229 | if !path.to_lowercase().ends_with("json") { 230 | return None; 231 | } 232 | if exprs.iter().any(|expr| expr.is_empty()) { 233 | return None; 234 | } 235 | Some(Query { 236 | path: Self::normalize_path(path), 237 | r#type: QueryType::JsonPath(exprs), 238 | }) 239 | } 240 | } 241 | 242 | /// Typed content of a [`CommitMessage`] 243 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 244 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 245 | #[serde(tag = "markup", content = "detail")] 246 | pub enum CommitDetail { 247 | /// Commit details as markdown 248 | Markdown(String), 249 | /// Commit details as plaintext 250 | Plaintext(String), 251 | } 252 | 253 | /// Description of a [`Commit`] 254 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 255 | #[serde(rename_all = "camelCase")] 256 | pub struct CommitMessage { 257 | /// Summary of this commit message 258 | pub summary: String, 259 | #[serde(flatten, skip_serializing_if = "Option::is_none")] 260 | /// Detailed description of this commit message 261 | pub detail: Option, 262 | } 263 | 264 | impl CommitMessage { 265 | pub fn only_summary(summary: &str) -> Self { 266 | CommitMessage { 267 | summary: summary.to_owned(), 268 | detail: None, 269 | } 270 | } 271 | } 272 | 273 | /// Result of a [push](trait@crate::ContentService#tymethod.push) operation. 274 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 275 | #[serde(rename_all = "camelCase")] 276 | pub struct PushResult { 277 | /// Revision of this commit. 278 | pub revision: Revision, 279 | /// When this commit was pushed. 280 | pub pushed_at: Option, 281 | } 282 | 283 | /// A set of Changes and its metadata. 284 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 285 | #[serde(rename_all = "camelCase")] 286 | pub struct Commit { 287 | /// Revision of this commit. 288 | pub revision: Revision, 289 | /// Author of this commit. 290 | pub author: Author, 291 | /// Description of this commit. 292 | pub commit_message: CommitMessage, 293 | /// When this commit was pushed. 294 | pub pushed_at: Option, 295 | } 296 | 297 | /// Typed content of a [`Change`]. 298 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 299 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 300 | #[serde(tag = "type", content = "content")] 301 | pub enum ChangeContent { 302 | /// Adds a new JSON file or replaces an existing file with the provided json. 303 | UpsertJson(serde_json::Value), 304 | 305 | /// Adds a new text file or replaces an existing file with the provided content. 306 | UpsertText(String), 307 | 308 | /// Removes an existing file. 309 | Remove, 310 | 311 | /// Renames an existsing file to this provided path. 312 | Rename(String), 313 | 314 | /// Applies a JSON patch to a JSON file with the provided JSON patch object, 315 | /// as defined in [RFC 6902](https://tools.ietf.org/html/rfc6902). 316 | ApplyJsonPatch(serde_json::Value), 317 | 318 | /// Applies a textual patch to a text file with the provided 319 | /// [unified format](https://en.wikipedia.org/wiki/Diff_utility#Unified_format) string. 320 | ApplyTextPatch(String), 321 | } 322 | 323 | /// A modification of an individual [`Entry`] 324 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 325 | #[serde(rename_all = "camelCase")] 326 | pub struct Change { 327 | /// Path of the file change. 328 | pub path: String, 329 | /// Content of the file change. 330 | #[serde(flatten)] 331 | pub content: ChangeContent, 332 | } 333 | 334 | /// A change result from a 335 | /// [watch_file](trait@crate::WatchService#tymethod.watch_file_stream) operation. 336 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 337 | #[serde(rename_all = "camelCase")] 338 | pub struct WatchFileResult { 339 | /// Revision of the change. 340 | pub revision: Revision, 341 | /// Content of the change. 342 | pub entry: Entry, 343 | } 344 | 345 | /// A change result from a 346 | /// [watch_repo](trait@crate::WatchService#tymethod.watch_repo_stream) operation. 347 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 348 | #[serde(rename_all = "camelCase")] 349 | pub struct WatchRepoResult { 350 | /// Revision of the change. 351 | pub revision: Revision, 352 | } 353 | 354 | /// A resource that is watchable 355 | /// Currently supported [`WatchFileResult`] and [`WatchRepoResult`] 356 | pub(crate) trait Watchable: DeserializeOwned + Send { 357 | fn revision(&self) -> Revision; 358 | } 359 | 360 | impl Watchable for WatchFileResult { 361 | fn revision(&self) -> Revision { 362 | self.revision 363 | } 364 | } 365 | 366 | impl Watchable for WatchRepoResult { 367 | fn revision(&self) -> Revision { 368 | self.revision 369 | } 370 | } 371 | 372 | #[cfg(test)] 373 | mod test { 374 | use super::*; 375 | 376 | #[test] 377 | fn test_query_identity() { 378 | let query = Query::identity("/a.json").unwrap(); 379 | 380 | assert_eq!(query.path, "/a.json"); 381 | assert_eq!(query.r#type, QueryType::Identity); 382 | } 383 | 384 | #[test] 385 | fn test_query_identity_auto_fix_path() { 386 | let query = Query::identity("a.json").unwrap(); 387 | 388 | assert_eq!(query.path, "/a.json"); 389 | assert_eq!(query.r#type, QueryType::Identity); 390 | } 391 | 392 | #[test] 393 | fn test_query_reject_empty_path() { 394 | let query = Query::identity(""); 395 | 396 | assert!(query.is_none()); 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /src/services/content.rs: -------------------------------------------------------------------------------- 1 | //! Content-related APIs 2 | use crate::{ 3 | model::{Change, Commit, CommitMessage, Entry, ListEntry, PushResult, Query, Revision}, 4 | services::{do_request, path}, 5 | Error, RepoClient, 6 | }; 7 | 8 | use async_trait::async_trait; 9 | use reqwest::{Body, Method}; 10 | use serde::Serialize; 11 | 12 | #[derive(Debug, Serialize)] 13 | #[serde(rename_all = "camelCase")] 14 | struct Push { 15 | commit_message: CommitMessage, 16 | changes: Vec, 17 | } 18 | 19 | /// Content-related APIs 20 | #[async_trait] 21 | pub trait ContentService { 22 | /// Retrieves the list of the files at the specified [`Revision`] matched by the path pattern. 23 | /// 24 | /// A path pattern is a variant of glob: 25 | /// * `"/**"` - find all files recursively 26 | /// * `"*.json"` - find all JSON files recursively 27 | /// * `"/foo/*.json"` - find all JSON files under the directory /foo 28 | /// * `"/*/foo.txt"` - find all files named foo.txt at the second depth level 29 | /// * `"*.json,/bar/*.txt"` - use comma to specify more than one pattern. 30 | /// A file will be matched if any pattern matches. 31 | async fn list_files( 32 | &self, 33 | revision: Revision, 34 | path_pattern: &str, 35 | ) -> Result, Error>; 36 | 37 | /// Queries a file at the specified [`Revision`] and path with the specified [`Query`]. 38 | async fn get_file(&self, revision: Revision, query: &Query) -> Result; 39 | 40 | /// Retrieves the files at the specified [`Revision`] matched by the path pattern. 41 | /// 42 | /// A path pattern is a variant of glob: 43 | /// * `"/**"` - find all files recursively 44 | /// * `"*.json"` - find all JSON files recursively 45 | /// * `"/foo/*.json"` - find all JSON files under the directory /foo 46 | /// * `"/*/foo.txt"` - find all files named foo.txt at the second depth level 47 | /// * `"*.json,/bar/*.txt"` - use comma to specify more than one pattern. 48 | /// A file will be matched if any pattern matches. 49 | async fn get_files(&self, revision: Revision, path_pattern: &str) -> Result, Error>; 50 | 51 | /// Retrieves the history of the repository of the files matched by the given 52 | /// path pattern between two [`Revision`]s. 53 | /// Note that this method does not retrieve the diffs but only metadata about the changes. 54 | /// Use [get_diff](#tymethod.get_diff) or 55 | /// [get_diffs](#tymethod.get_diffs) to retrieve the diffs 56 | async fn get_history( 57 | &self, 58 | from_rev: Revision, 59 | to_rev: Revision, 60 | path: &str, 61 | max_commits: Option, 62 | ) -> Result, Error>; 63 | 64 | /// Returns the diff of a file between two [`Revision`]s. 65 | async fn get_diff( 66 | &self, 67 | from_rev: Revision, 68 | to_rev: Revision, 69 | query: &Query, 70 | ) -> Result; 71 | 72 | /// Retrieves the diffs of the files matched by the given 73 | /// path pattern between two [`Revision`]s. 74 | /// 75 | /// A path pattern is a variant of glob: 76 | /// * `"/**"` - find all files recursively 77 | /// * `"*.json"` - find all JSON files recursively 78 | /// * `"/foo/*.json"` - find all JSON files under the directory /foo 79 | /// * `"/*/foo.txt"` - find all files named foo.txt at the second depth level 80 | /// * `"*.json,/bar/*.txt"` - use comma to specify more than one pattern. 81 | /// A file will be matched if any pattern matches. 82 | async fn get_diffs( 83 | &self, 84 | from_rev: Revision, 85 | to_rev: Revision, 86 | path_pattern: &str, 87 | ) -> Result, Error>; 88 | 89 | /// Pushes the specified [`Change`]s to the repository. 90 | async fn push( 91 | &self, 92 | base_revision: Revision, 93 | cm: CommitMessage, 94 | changes: Vec, 95 | ) -> Result; 96 | } 97 | 98 | #[async_trait] 99 | impl<'a> ContentService for RepoClient<'a> { 100 | async fn list_files( 101 | &self, 102 | revision: Revision, 103 | path_pattern: &str, 104 | ) -> Result, Error> { 105 | let req = self.client.new_request( 106 | Method::GET, 107 | path::list_contents_path(self.project, self.repo, revision, path_pattern), 108 | None, 109 | )?; 110 | 111 | do_request(self.client, req).await 112 | } 113 | 114 | async fn get_file(&self, revision: Revision, query: &Query) -> Result { 115 | let p = path::content_path(self.project, self.repo, revision, query); 116 | let req = self.client.new_request(Method::GET, p, None)?; 117 | 118 | do_request(self.client, req).await 119 | } 120 | 121 | async fn get_files(&self, revision: Revision, path_pattern: &str) -> Result, Error> { 122 | let req = self.client.new_request( 123 | Method::GET, 124 | path::contents_path(self.project, self.repo, revision, path_pattern), 125 | None, 126 | )?; 127 | 128 | do_request(self.client, req).await 129 | } 130 | 131 | async fn get_history( 132 | &self, 133 | from_rev: Revision, 134 | to_rev: Revision, 135 | path: &str, 136 | max_commits: Option, 137 | ) -> Result, Error> { 138 | let p = path::content_commits_path( 139 | self.project, 140 | self.repo, 141 | from_rev, 142 | to_rev, 143 | path, 144 | max_commits, 145 | ); 146 | let req = self.client.new_request(Method::GET, p, None)?; 147 | 148 | do_request(self.client, req).await 149 | } 150 | 151 | async fn get_diff( 152 | &self, 153 | from_rev: Revision, 154 | to_rev: Revision, 155 | query: &Query, 156 | ) -> Result { 157 | let p = path::content_compare_path(self.project, self.repo, from_rev, to_rev, query); 158 | let req = self.client.new_request(Method::GET, p, None)?; 159 | 160 | do_request(self.client, req).await 161 | } 162 | 163 | async fn get_diffs( 164 | &self, 165 | from_rev: Revision, 166 | to_rev: Revision, 167 | path_pattern: &str, 168 | ) -> Result, Error> { 169 | let p = 170 | path::contents_compare_path(self.project, self.repo, from_rev, to_rev, path_pattern); 171 | let req = self.client.new_request(Method::GET, p, None)?; 172 | 173 | do_request(self.client, req).await 174 | } 175 | 176 | async fn push( 177 | &self, 178 | base_revision: Revision, 179 | cm: CommitMessage, 180 | changes: Vec, 181 | ) -> Result { 182 | if cm.summary.is_empty() { 183 | return Err(Error::InvalidParams( 184 | "summary of commit_message cannot be empty", 185 | )); 186 | } 187 | if changes.is_empty() { 188 | return Err(Error::InvalidParams("no changes to commit")); 189 | } 190 | 191 | let body: String = serde_json::to_string(&Push { 192 | commit_message: cm, 193 | changes, 194 | })?; 195 | let body = Body::from(body); 196 | 197 | let p = path::contents_push_path(self.project, self.repo, base_revision); 198 | let req = self.client.new_request(Method::POST, p, Some(body))?; 199 | 200 | do_request(self.client, req).await 201 | } 202 | } 203 | 204 | #[cfg(test)] 205 | mod test { 206 | use super::*; 207 | use crate::{ 208 | model::{Author, ChangeContent, EntryContent, EntryType, Revision}, 209 | Client, 210 | }; 211 | use wiremock::{ 212 | matchers::{body_json, header, method, path, query_param}, 213 | Mock, MockServer, ResponseTemplate, 214 | }; 215 | 216 | #[tokio::test] 217 | async fn test_list_files() { 218 | let server = MockServer::start().await; 219 | let resp = ResponseTemplate::new(200).set_body_raw( 220 | r#"[ 221 | {"path":"/a.json", "type":"JSON"}, 222 | {"path":"/b.txt", "type":"TEXT"} 223 | ]"#, 224 | "application/json", 225 | ); 226 | Mock::given(method("GET")) 227 | .and(path("/api/v1/projects/foo/repos/bar/list/**")) 228 | .and(header("Authorization", "Bearer anonymous")) 229 | .respond_with(resp) 230 | .mount(&server) 231 | .await; 232 | 233 | let client = Client::new(&server.uri(), None).await.unwrap(); 234 | let entries = client 235 | .repo("foo", "bar") 236 | .list_files(Revision::HEAD, "/**") 237 | .await 238 | .unwrap(); 239 | 240 | server.reset().await; 241 | let expected = [("/a.json", EntryType::Json), ("/b.txt", EntryType::Text)]; 242 | 243 | for (p, e) in entries.iter().zip(expected.iter()) { 244 | assert_eq!(p.path, e.0); 245 | assert_eq!(p.r#type, e.1); 246 | } 247 | } 248 | 249 | #[tokio::test] 250 | async fn test_list_files_with_revision() { 251 | let server = MockServer::start().await; 252 | let resp = ResponseTemplate::new(200).set_body_raw( 253 | r#"[ 254 | {"path":"/a.json", "type":"JSON"}, 255 | {"path":"/b.txt", "type":"TEXT"} 256 | ]"#, 257 | "application/json", 258 | ); 259 | Mock::given(method("GET")) 260 | .and(path("/api/v1/projects/foo/repos/bar/list/**")) 261 | .and(query_param("revision", "2")) 262 | .and(header("Authorization", "Bearer anonymous")) 263 | .respond_with(resp) 264 | .mount(&server) 265 | .await; 266 | 267 | let client = Client::new(&server.uri(), None).await.unwrap(); 268 | let entries = client 269 | .repo("foo", "bar") 270 | .list_files(Revision::from(2), "/**") 271 | .await 272 | .unwrap(); 273 | 274 | server.reset().await; 275 | let expected = [("/a.json", EntryType::Json), ("/b.txt", EntryType::Text)]; 276 | 277 | for (p, e) in entries.iter().zip(expected.iter()) { 278 | assert_eq!(p.path, e.0); 279 | assert_eq!(p.r#type, e.1); 280 | } 281 | } 282 | 283 | #[tokio::test] 284 | async fn test_get_file() { 285 | let server = MockServer::start().await; 286 | let resp = ResponseTemplate::new(200).set_body_raw( 287 | r#"{ 288 | "path":"/b.txt", 289 | "type":"TEXT", 290 | "revision":2, 291 | "url": "/api/v1/projects/foo/repos/bar/contents/b.txt", 292 | "content":"hello world~!" 293 | }"#, 294 | "application/json", 295 | ); 296 | Mock::given(method("GET")) 297 | .and(path("/api/v1/projects/foo/repos/bar/contents/b.txt")) 298 | .and(header("Authorization", "Bearer anonymous")) 299 | .respond_with(resp) 300 | .mount(&server) 301 | .await; 302 | 303 | let client = Client::new(&server.uri(), None).await.unwrap(); 304 | let entry = client 305 | .repo("foo", "bar") 306 | .get_file(Revision::HEAD, &Query::identity("/b.txt").unwrap()) 307 | .await 308 | .unwrap(); 309 | 310 | server.reset().await; 311 | assert_eq!(entry.path, "/b.txt"); 312 | assert!(matches!(entry.content, EntryContent::Text(t) if t == "hello world~!")); 313 | } 314 | 315 | #[tokio::test] 316 | async fn test_get_file_text_with_escape() { 317 | let server = MockServer::start().await; 318 | let content = "foo\nb\"rb\\z"; 319 | let resp = ResponseTemplate::new(200).set_body_json(serde_json::json!({ 320 | "path":"/b.txt", 321 | "type":"TEXT", 322 | "revision":2, 323 | "url": "/api/v1/projects/foo/repos/bar/contents/b.txt", 324 | "content":content 325 | })); 326 | Mock::given(method("GET")) 327 | .and(path("/api/v1/projects/foo/repos/bar/contents/b.txt")) 328 | .and(header("Authorization", "Bearer anonymous")) 329 | .respond_with(resp) 330 | .mount(&server) 331 | .await; 332 | 333 | let client = Client::new(&server.uri(), None).await.unwrap(); 334 | let entry = client 335 | .repo("foo", "bar") 336 | .get_file(Revision::HEAD, &Query::identity("/b.txt").unwrap()) 337 | .await 338 | .unwrap(); 339 | 340 | server.reset().await; 341 | assert_eq!(entry.path, "/b.txt"); 342 | assert!(matches!(entry.content, EntryContent::Text(t) if t == content)); 343 | } 344 | 345 | #[tokio::test] 346 | async fn test_get_file_json() { 347 | let server = MockServer::start().await; 348 | let resp = ResponseTemplate::new(200).set_body_raw( 349 | r#"{ 350 | "path":"/a.json", 351 | "type":"JSON", 352 | "revision":2, 353 | "url": "/api/v1/projects/foo/repos/bar/contents/a.json", 354 | "content":{"a":"b"} 355 | }"#, 356 | "application/json", 357 | ); 358 | Mock::given(method("GET")) 359 | .and(path("/api/v1/projects/foo/repos/bar/contents/a.json")) 360 | .and(header("Authorization", "Bearer anonymous")) 361 | .respond_with(resp) 362 | .mount(&server) 363 | .await; 364 | 365 | let client = Client::new(&server.uri(), None).await.unwrap(); 366 | let entry = client 367 | .repo("foo", "bar") 368 | .get_file(Revision::HEAD, &Query::identity("/a.json").unwrap()) 369 | .await 370 | .unwrap(); 371 | 372 | server.reset().await; 373 | assert_eq!(entry.path, "/a.json"); 374 | let expected = serde_json::json!({"a": "b"}); 375 | assert!(matches!(entry.content, EntryContent::Json(js) if js == expected)); 376 | } 377 | 378 | #[tokio::test] 379 | async fn test_get_file_json_path() { 380 | let server = MockServer::start().await; 381 | let resp = ResponseTemplate::new(200).set_body_raw( 382 | r#"{ 383 | "path":"/a.json", 384 | "type":"JSON", 385 | "revision":2, 386 | "url": "/api/v1/projects/foo/repos/bar/contents/a.json", 387 | "content":"b" 388 | }"#, 389 | "application/json", 390 | ); 391 | Mock::given(method("GET")) 392 | .and(path("/api/v1/projects/foo/repos/bar/contents/a.json")) 393 | .and(query_param("jsonpath", "$.a")) 394 | .and(header("Authorization", "Bearer anonymous")) 395 | .respond_with(resp) 396 | .mount(&server) 397 | .await; 398 | 399 | let client = Client::new(&server.uri(), None).await.unwrap(); 400 | let query = Query::of_json_path("/a.json", vec!["$.a".to_string()]).unwrap(); 401 | let entry = client 402 | .repo("foo", "bar") 403 | .get_file(Revision::HEAD, &query) 404 | .await 405 | .unwrap(); 406 | 407 | server.reset().await; 408 | assert_eq!(entry.path, "/a.json"); 409 | let expected = serde_json::json!("b"); 410 | assert!(matches!(entry.content, EntryContent::Json(js) if js == expected)); 411 | } 412 | 413 | #[tokio::test] 414 | async fn test_get_file_json_path_and_revision() { 415 | let server = MockServer::start().await; 416 | let resp = ResponseTemplate::new(200).set_body_raw( 417 | r#"{ 418 | "path":"/a.json", 419 | "type":"JSON", 420 | "revision":2, 421 | "url": "/api/v1/projects/foo/repos/bar/contents/a.json", 422 | "content":"b" 423 | }"#, 424 | "application/json", 425 | ); 426 | Mock::given(method("GET")) 427 | .and(path("/api/v1/projects/foo/repos/bar/contents/a.json")) 428 | .and(query_param("revision", "5")) 429 | .and(query_param("jsonpath", "$.a")) 430 | .and(header("Authorization", "Bearer anonymous")) 431 | .respond_with(resp) 432 | .mount(&server) 433 | .await; 434 | 435 | let client = Client::new(&server.uri(), None).await.unwrap(); 436 | let query = Query::of_json_path("/a.json", vec!["$.a".to_string()]).unwrap(); 437 | let entry = client 438 | .repo("foo", "bar") 439 | .get_file(Revision::from(5), &query) 440 | .await 441 | .unwrap(); 442 | 443 | server.reset().await; 444 | assert_eq!(entry.path, "/a.json"); 445 | let expected = serde_json::json!("b"); 446 | assert!(matches!(entry.content, EntryContent::Json(js) if js == expected)); 447 | } 448 | 449 | #[tokio::test] 450 | async fn test_get_files() { 451 | let server = MockServer::start().await; 452 | let resp = ResponseTemplate::new(200).set_body_raw( 453 | r#"[{ 454 | "path":"/a.json", 455 | "type":"JSON", 456 | "revision":2, 457 | "url": "/api/v1/projects/foo/repos/bar/contents/a.json", 458 | "content":{"a":"b"} 459 | }, { 460 | "path":"/b.txt", 461 | "type":"TEXT", 462 | "revision":2, 463 | "url": "/api/v1/projects/foo/repos/bar/contents/b.txt", 464 | "content":"hello world~!" 465 | }]"#, 466 | "application/json", 467 | ); 468 | Mock::given(method("GET")) 469 | .and(path("/api/v1/projects/foo/repos/bar/contents/**")) 470 | .and(header("Authorization", "Bearer anonymous")) 471 | .respond_with(resp) 472 | .mount(&server) 473 | .await; 474 | 475 | let client = Client::new(&server.uri(), None).await.unwrap(); 476 | let entries = client 477 | .repo("foo", "bar") 478 | .get_files(Revision::HEAD, "/**") 479 | .await 480 | .unwrap(); 481 | 482 | server.reset().await; 483 | let expected = [ 484 | ("/a.json", EntryContent::Json(serde_json::json!({"a":"b"}))), 485 | ("/b.txt", EntryContent::Text("hello world~!".to_string())), 486 | ]; 487 | 488 | for (p, e) in entries.iter().zip(expected.iter()) { 489 | assert_eq!(p.path, e.0); 490 | assert_eq!(p.content, e.1); 491 | } 492 | } 493 | 494 | #[tokio::test] 495 | async fn test_get_history() { 496 | let server = MockServer::start().await; 497 | let resp = ResponseTemplate::new(200).set_body_raw( 498 | r#"[{ 499 | "revision":1, 500 | "author":{"name":"minux", "email":"minux@m.x"}, 501 | "commitMessage":{"summary":"Add a.json"} 502 | }, { 503 | "revision":2, 504 | "author":{"name":"minux", "email":"minux@m.x"}, 505 | "commitMessage":{"summary":"Edit a.json"} 506 | }]"#, 507 | "application/json", 508 | ); 509 | Mock::given(method("GET")) 510 | .and(path("/api/v1/projects/foo/repos/bar/commits/-2")) 511 | .and(query_param("to", "-1")) 512 | .and(query_param("maxCommits", "2")) 513 | .and(header("Authorization", "Bearer anonymous")) 514 | .respond_with(resp) 515 | .mount(&server) 516 | .await; 517 | 518 | let client = Client::new(&server.uri(), None).await.unwrap(); 519 | let commits = client 520 | .repo("foo", "bar") 521 | .get_history(Revision::from(-2), Revision::HEAD, "/**", Some(2)) 522 | .await 523 | .unwrap(); 524 | 525 | let expected = [ 526 | ( 527 | 1, 528 | Author { 529 | name: "minux".to_string(), 530 | email: "minux@m.x".to_string(), 531 | }, 532 | CommitMessage { 533 | summary: "Add a.json".to_string(), 534 | detail: None, 535 | }, 536 | ), 537 | ( 538 | 2, 539 | Author { 540 | name: "minux".to_string(), 541 | email: "minux@m.x".to_string(), 542 | }, 543 | CommitMessage { 544 | summary: "Edit a.json".to_string(), 545 | detail: None, 546 | }, 547 | ), 548 | ]; 549 | 550 | server.reset().await; 551 | for (p, e) in commits.iter().zip(expected.iter()) { 552 | assert_eq!(p.revision.as_i64(), Some(e.0)); 553 | assert_eq!(p.author, e.1); 554 | assert_eq!(p.commit_message, e.2); 555 | } 556 | } 557 | 558 | #[tokio::test] 559 | async fn test_get_diff() { 560 | let server = MockServer::start().await; 561 | let resp = ResponseTemplate::new(200).set_body_raw( 562 | r#"{ 563 | "path":"/a.json", 564 | "type":"APPLY_JSON_PATCH", 565 | "content":[{ 566 | "op":"safeReplace", 567 | "path":"", 568 | "oldValue":"bar", 569 | "value":"baz" 570 | }] 571 | }"#, 572 | "application/json", 573 | ); 574 | Mock::given(method("GET")) 575 | .and(path("/api/v1/projects/foo/repos/bar/compare")) 576 | .and(query_param("from", "3")) 577 | .and(query_param("to", "4")) 578 | .and(query_param("path", "/a.json")) 579 | .and(query_param("jsonpath", "$.a")) 580 | .and(header("Authorization", "Bearer anonymous")) 581 | .respond_with(resp) 582 | .mount(&server) 583 | .await; 584 | 585 | let client = Client::new(&server.uri(), None).await.unwrap(); 586 | let query = Query::of_json_path("/a.json", vec!["$.a".to_string()]).unwrap(); 587 | let change = client 588 | .repo("foo", "bar") 589 | .get_diff(Revision::from(3), Revision::from(4), &query) 590 | .await 591 | .unwrap(); 592 | 593 | let expected = Change { 594 | path: "/a.json".to_string(), 595 | content: ChangeContent::ApplyJsonPatch(serde_json::json!([{ 596 | "op": "safeReplace", 597 | "path": "", 598 | "oldValue": "bar", 599 | "value": "baz" 600 | }])), 601 | }; 602 | 603 | server.reset().await; 604 | assert_eq!(change, expected); 605 | } 606 | 607 | #[tokio::test] 608 | async fn test_get_diffs() { 609 | let server = MockServer::start().await; 610 | let resp = ResponseTemplate::new(200).set_body_raw( 611 | r#"[{ 612 | "path":"/a.json", 613 | "type":"APPLY_JSON_PATCH", 614 | "content":[{ 615 | "op":"safeReplace", 616 | "path":"", 617 | "oldValue":"bar", 618 | "value":"baz" 619 | }] 620 | }, { 621 | "path":"/b.txt", 622 | "type":"APPLY_TEXT_PATCH", 623 | "content":"--- /b.txt\n+++ /b.txt\n@@ -1,1 +1,1 @@\n-foo\n+bar" 624 | }]"#, 625 | "application/json", 626 | ); 627 | Mock::given(method("GET")) 628 | .and(path("/api/v1/projects/foo/repos/bar/compare")) 629 | .and(query_param("from", "1")) 630 | .and(query_param("to", "4")) 631 | .and(query_param("pathPattern", "/**")) 632 | .and(header("Authorization", "Bearer anonymous")) 633 | .respond_with(resp) 634 | .mount(&server) 635 | .await; 636 | 637 | let client = Client::new(&server.uri(), None).await.unwrap(); 638 | let changes = client 639 | .repo("foo", "bar") 640 | .get_diffs(Revision::from(1), Revision::from(4), "/**") 641 | .await 642 | .unwrap(); 643 | 644 | let expected = [ 645 | Change { 646 | path: "/a.json".to_string(), 647 | content: ChangeContent::ApplyJsonPatch(serde_json::json!([{ 648 | "op": "safeReplace", 649 | "path": "", 650 | "oldValue": "bar", 651 | "value": "baz" 652 | }])), 653 | }, 654 | Change { 655 | path: "/b.txt".to_string(), 656 | content: ChangeContent::ApplyTextPatch( 657 | "--- /b.txt\n+++ /b.txt\n@@ -1,1 +1,1 @@\n-foo\n+bar".to_string(), 658 | ), 659 | }, 660 | ]; 661 | 662 | server.reset().await; 663 | for (c, e) in changes.iter().zip(expected.iter()) { 664 | assert_eq!(c, e); 665 | } 666 | } 667 | 668 | #[tokio::test] 669 | async fn test_push() { 670 | let server = MockServer::start().await; 671 | let resp = ResponseTemplate::new(200).set_body_raw( 672 | r#"{ 673 | "revision":2, 674 | "pushedAt":"2017-05-22T00:00:00Z" 675 | }"#, 676 | "application/json", 677 | ); 678 | 679 | let changes = vec![Change { 680 | path: "/a.json".to_string(), 681 | content: ChangeContent::UpsertJson(serde_json::json!({"a":"b"})), 682 | }]; 683 | let body = Push { 684 | commit_message: CommitMessage::only_summary("Add a.json"), 685 | changes, 686 | }; 687 | Mock::given(method("POST")) 688 | .and(path("/api/v1/projects/foo/repos/bar/contents")) 689 | .and(query_param("revision", "-1")) 690 | .and(body_json(body)) 691 | .and(header("Authorization", "Bearer anonymous")) 692 | .respond_with(resp) 693 | .expect(1) 694 | .mount(&server) 695 | .await; 696 | 697 | let client = Client::new(&server.uri(), None).await.unwrap(); 698 | let changes = vec![Change { 699 | path: "/a.json".to_string(), 700 | content: ChangeContent::UpsertJson(serde_json::json!({"a":"b"})), 701 | }]; 702 | let result = client 703 | .repo("foo", "bar") 704 | .push( 705 | Revision::HEAD, 706 | CommitMessage::only_summary("Add a.json"), 707 | changes, 708 | ) 709 | .await; 710 | 711 | let expected = PushResult { 712 | revision: Revision::from(2), 713 | pushed_at: Some("2017-05-22T00:00:00Z".to_string()), 714 | }; 715 | 716 | drop(server); 717 | assert_eq!(result.unwrap(), expected); 718 | } 719 | 720 | #[tokio::test] 721 | async fn test_push_two_files() { 722 | let server = MockServer::start().await; 723 | let resp = ResponseTemplate::new(200).set_body_raw( 724 | r#"{ 725 | "revision":3, 726 | "pushedAt":"2017-05-22T00:00:00Z" 727 | }"#, 728 | "application/json", 729 | ); 730 | 731 | let changes = vec![ 732 | Change { 733 | path: "/a.json".to_string(), 734 | content: ChangeContent::UpsertJson(serde_json::json!({"a":"b"})), 735 | }, 736 | Change { 737 | path: "/b.txt".to_string(), 738 | content: ChangeContent::UpsertText("myContent".to_string()), 739 | }, 740 | ]; 741 | let body = Push { 742 | commit_message: CommitMessage::only_summary("Add a.json and b.txt"), 743 | changes, 744 | }; 745 | Mock::given(method("POST")) 746 | .and(path("/api/v1/projects/foo/repos/bar/contents")) 747 | .and(query_param("revision", "-1")) 748 | .and(body_json(body)) 749 | .and(header("Authorization", "Bearer anonymous")) 750 | .respond_with(resp) 751 | .expect(1) 752 | .mount(&server) 753 | .await; 754 | 755 | let client = Client::new(&server.uri(), None).await.unwrap(); 756 | let changes = vec![ 757 | Change { 758 | path: "/a.json".to_string(), 759 | content: ChangeContent::UpsertJson(serde_json::json!({"a":"b"})), 760 | }, 761 | Change { 762 | path: "/b.txt".to_string(), 763 | content: ChangeContent::UpsertText("myContent".to_string()), 764 | }, 765 | ]; 766 | let result = client 767 | .repo("foo", "bar") 768 | .push( 769 | Revision::HEAD, 770 | CommitMessage::only_summary("Add a.json and b.txt"), 771 | changes, 772 | ) 773 | .await; 774 | 775 | let expected = PushResult { 776 | revision: Revision::from(3), 777 | pushed_at: Some("2017-05-22T00:00:00Z".to_string()), 778 | }; 779 | 780 | drop(server); 781 | assert_eq!(result.unwrap(), expected); 782 | } 783 | } 784 | -------------------------------------------------------------------------------- /src/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod content; 2 | mod path; 3 | pub mod project; 4 | pub mod repository; 5 | pub mod watch; 6 | 7 | use reqwest::Response; 8 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 9 | 10 | use crate::{Client, Error}; 11 | 12 | #[derive(Debug, Serialize, Deserialize)] 13 | #[serde(rename_all = "camelCase")] 14 | struct ErrorMessage { 15 | message: String, 16 | } 17 | 18 | /// convert HTTP Response with status < 200 and > 300 to Error 19 | async fn status_unwrap(resp: Response) -> Result { 20 | match resp.status().as_u16() { 21 | code if !(200..300).contains(&code) => { 22 | let err_body = resp.text().await?; 23 | let err_msg: ErrorMessage = 24 | serde_json::from_str(&err_body).unwrap_or(ErrorMessage { message: err_body }); 25 | 26 | Err(Error::ErrorResponse(code, err_msg.message)) 27 | } 28 | _ => Ok(resp), 29 | } 30 | } 31 | 32 | pub(super) async fn do_request( 33 | client: &Client, 34 | req: reqwest::Request, 35 | ) -> Result { 36 | let resp = client.request(req).await?; 37 | let ok_resp = status_unwrap(resp).await?; 38 | let result = ok_resp.json().await?; 39 | 40 | Ok(result) 41 | } 42 | -------------------------------------------------------------------------------- /src/services/path.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use crate::model::{Query, QueryType, Revision}; 4 | 5 | const PATH_PREFIX: &str = "/api/v1"; 6 | 7 | mod params { 8 | pub const REVISION: &str = "revision"; 9 | pub const JSONPATH: &str = "jsonpath"; 10 | pub const PATH: &str = "path"; 11 | pub const PATH_PATTERN: &str = "pathPattern"; 12 | pub const MAX_COMMITS: &str = "maxCommits"; 13 | pub const FROM: &str = "from"; 14 | pub const TO: &str = "to"; 15 | } 16 | 17 | fn normalize_path_pattern(path_pattern: &str) -> Cow { 18 | if path_pattern.is_empty() { 19 | return Cow::Borrowed("/**"); 20 | } 21 | if path_pattern.starts_with("**") { 22 | return Cow::Owned(format!("/{}", path_pattern)); 23 | } 24 | if !path_pattern.starts_with('/') { 25 | return Cow::Owned(format!("/**/{}", path_pattern)); 26 | } 27 | 28 | Cow::Borrowed(path_pattern) 29 | } 30 | 31 | pub(crate) fn projects_path() -> String { 32 | format!("{}/projects", PATH_PREFIX) 33 | } 34 | 35 | pub(crate) fn removed_projects_path() -> String { 36 | format!("{}/projects?status=removed", PATH_PREFIX) 37 | } 38 | 39 | pub(crate) fn project_path(project_name: &str) -> String { 40 | format!("{}/projects/{}", PATH_PREFIX, project_name) 41 | } 42 | 43 | pub(crate) fn removed_project_path(project_name: &str) -> String { 44 | format!("{}/projects/{}/removed", PATH_PREFIX, project_name) 45 | } 46 | 47 | pub(crate) fn repos_path(project_name: &str) -> String { 48 | format!("{}/projects/{}/repos", PATH_PREFIX, project_name) 49 | } 50 | 51 | pub(crate) fn removed_repos_path(project_name: &str) -> String { 52 | format!( 53 | "{}/projects/{}/repos?status=removed", 54 | PATH_PREFIX, project_name 55 | ) 56 | } 57 | 58 | pub(crate) fn repo_path(project_name: &str, repo_name: &str) -> String { 59 | format!( 60 | "{}/projects/{}/repos/{}", 61 | PATH_PREFIX, project_name, repo_name 62 | ) 63 | } 64 | 65 | pub(crate) fn removed_repo_path(project_name: &str, repo_name: &str) -> String { 66 | format!( 67 | "{}/projects/{}/repos/{}/removed", 68 | PATH_PREFIX, project_name, repo_name 69 | ) 70 | } 71 | 72 | pub(crate) fn list_contents_path( 73 | project_name: &str, 74 | repo_name: &str, 75 | revision: Revision, 76 | path_pattern: &str, 77 | ) -> String { 78 | let path_pattern = normalize_path_pattern(path_pattern); 79 | let url = format!( 80 | "{}/projects/{}/repos/{}/list{}?", 81 | PATH_PREFIX, project_name, repo_name, &path_pattern 82 | ); 83 | let len = url.len(); 84 | 85 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 86 | if let Some(v) = revision.as_ref() { 87 | add_pair(&mut s, params::REVISION, &v.to_string()); 88 | } 89 | 90 | s.finish() 91 | } 92 | 93 | pub(crate) fn contents_path( 94 | project_name: &str, 95 | repo_name: &str, 96 | revision: Revision, 97 | path_pattern: &str, 98 | ) -> String { 99 | let path_pattern = normalize_path_pattern(path_pattern); 100 | let url = format!( 101 | "{}/projects/{}/repos/{}/contents{}?", 102 | PATH_PREFIX, project_name, repo_name, path_pattern 103 | ); 104 | let len = url.len(); 105 | 106 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 107 | if let Some(v) = revision.as_ref() { 108 | add_pair(&mut s, params::REVISION, &v.to_string()); 109 | } 110 | 111 | s.finish() 112 | } 113 | 114 | pub(crate) fn content_path( 115 | project_name: &str, 116 | repo_name: &str, 117 | revision: Revision, 118 | query: &Query, 119 | ) -> String { 120 | let url = format!( 121 | "{}/projects/{}/repos/{}/contents{}?", 122 | PATH_PREFIX, project_name, repo_name, &query.path 123 | ); 124 | 125 | let len = url.len(); 126 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 127 | if let Some(v) = revision.as_ref() { 128 | add_pair(&mut s, params::REVISION, &v.to_string()); 129 | } 130 | 131 | if let QueryType::JsonPath(expressions) = &query.r#type { 132 | for expression in expressions.iter() { 133 | add_pair(&mut s, params::JSONPATH, expression); 134 | } 135 | } 136 | 137 | s.finish() 138 | } 139 | 140 | pub(crate) fn content_commits_path( 141 | project_name: &str, 142 | repo_name: &str, 143 | from_rev: Revision, 144 | to_rev: Revision, 145 | path: &str, 146 | max_commits: Option, 147 | ) -> String { 148 | let url = format!( 149 | "{}/projects/{}/repos/{}/commits/{}?", 150 | PATH_PREFIX, 151 | project_name, 152 | repo_name, 153 | &from_rev.to_string(), 154 | ); 155 | 156 | let len = url.len(); 157 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 158 | add_pair(&mut s, params::PATH, path); 159 | 160 | if let Some(v) = to_rev.as_ref() { 161 | add_pair(&mut s, params::TO, &v.to_string()); 162 | } 163 | 164 | if let Some(c) = max_commits { 165 | add_pair(&mut s, params::MAX_COMMITS, &c.to_string()); 166 | } 167 | 168 | s.finish() 169 | } 170 | 171 | pub(crate) fn content_compare_path( 172 | project_name: &str, 173 | repo_name: &str, 174 | from_rev: Revision, 175 | to_rev: Revision, 176 | query: &Query, 177 | ) -> String { 178 | let url = format!( 179 | "{}/projects/{}/repos/{}/compare?", 180 | PATH_PREFIX, project_name, repo_name 181 | ); 182 | 183 | let len = url.len(); 184 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 185 | add_pair(&mut s, params::PATH, &query.path); 186 | 187 | if let Some(v) = from_rev.as_ref() { 188 | add_pair(&mut s, params::FROM, &v.to_string()); 189 | } 190 | if let Some(v) = to_rev.as_ref() { 191 | add_pair(&mut s, params::TO, &v.to_string()); 192 | } 193 | 194 | if let QueryType::JsonPath(expressions) = &query.r#type { 195 | for expression in expressions.iter() { 196 | add_pair(&mut s, params::JSONPATH, expression); 197 | } 198 | } 199 | 200 | s.finish() 201 | } 202 | 203 | pub(crate) fn contents_compare_path( 204 | project_name: &str, 205 | repo_name: &str, 206 | from_rev: Revision, 207 | to_rev: Revision, 208 | path_pattern: &str, 209 | ) -> String { 210 | let url = format!( 211 | "{}/projects/{}/repos/{}/compare?", 212 | PATH_PREFIX, project_name, repo_name 213 | ); 214 | 215 | let path_pattern = normalize_path_pattern(path_pattern); 216 | let len = url.len(); 217 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 218 | add_pair(&mut s, params::PATH_PATTERN, &path_pattern); 219 | 220 | if let Some(v) = from_rev.as_ref() { 221 | add_pair(&mut s, params::FROM, &v.to_string()); 222 | } 223 | if let Some(v) = to_rev.as_ref() { 224 | add_pair(&mut s, params::TO, &v.to_string()); 225 | } 226 | 227 | s.finish() 228 | } 229 | 230 | pub(crate) fn contents_push_path( 231 | project_name: &str, 232 | repo_name: &str, 233 | base_revision: Revision, 234 | ) -> String { 235 | let url = format!( 236 | "{}/projects/{}/repos/{}/contents?", 237 | PATH_PREFIX, project_name, repo_name 238 | ); 239 | 240 | let len = url.len(); 241 | let mut s = form_urlencoded::Serializer::for_suffix(url, len); 242 | 243 | if let Some(v) = base_revision.as_ref() { 244 | add_pair(&mut s, params::REVISION, &v.to_string()); 245 | } 246 | 247 | s.finish() 248 | } 249 | 250 | pub(crate) fn content_watch_path(project_name: &str, repo_name: &str, query: &Query) -> String { 251 | let url = format!( 252 | "{}/projects/{}/repos/{}/contents{}?", 253 | PATH_PREFIX, project_name, repo_name, &query.path 254 | ); 255 | 256 | let len = url.len(); 257 | let mut serializer = form_urlencoded::Serializer::for_suffix(url, len); 258 | 259 | if let QueryType::JsonPath(expressions) = &query.r#type { 260 | for expression in expressions.iter() { 261 | add_pair(&mut serializer, params::JSONPATH, expression); 262 | } 263 | } 264 | 265 | serializer.finish() 266 | } 267 | 268 | pub(crate) fn repo_watch_path(project_name: &str, repo_name: &str, path_pattern: &str) -> String { 269 | let path_pattern = normalize_path_pattern(path_pattern); 270 | 271 | format!( 272 | "{}/projects/{}/repos/{}/contents{}", 273 | PATH_PREFIX, project_name, repo_name, path_pattern 274 | ) 275 | } 276 | 277 | fn add_pair<'a, T>(s: &mut form_urlencoded::Serializer<'a, T>, key: &str, value: &str) 278 | where 279 | T: form_urlencoded::Target, 280 | { 281 | if !value.is_empty() { 282 | s.append_pair(key, value); 283 | } 284 | } 285 | 286 | #[cfg(test)] 287 | mod test { 288 | use super::*; 289 | 290 | #[test] 291 | fn test_content_commits_path() { 292 | let full_arg_path = content_commits_path( 293 | "foo", 294 | "bar", 295 | Revision::from(1), 296 | Revision::from(2), 297 | "/a.json", 298 | Some(5), 299 | ); 300 | assert_eq!( 301 | full_arg_path, 302 | "/api/v1/projects/foo/repos/bar/commits/1?path=%2Fa.json&to=2&maxCommits=5" 303 | ); 304 | 305 | let omitted_max_commmit_path = content_commits_path( 306 | "foo", 307 | "bar", 308 | Revision::from(1), 309 | Revision::from(2), 310 | "/a.json", 311 | None, 312 | ); 313 | assert_eq!( 314 | omitted_max_commmit_path, 315 | "/api/v1/projects/foo/repos/bar/commits/1?path=%2Fa.json&to=2" 316 | ); 317 | 318 | let omitted_from_to_path = content_commits_path( 319 | "foo", 320 | "bar", 321 | Revision::DEFAULT, 322 | Revision::DEFAULT, 323 | "/a.json", 324 | Some(5), 325 | ); 326 | assert_eq!( 327 | omitted_from_to_path, 328 | "/api/v1/projects/foo/repos/bar/commits/?path=%2Fa.json&maxCommits=5" 329 | ); 330 | 331 | let omitted_all_path = content_commits_path( 332 | "foo", 333 | "bar", 334 | Revision::DEFAULT, 335 | Revision::DEFAULT, 336 | "/a.json", 337 | None, 338 | ); 339 | assert_eq!( 340 | omitted_all_path, 341 | "/api/v1/projects/foo/repos/bar/commits/?path=%2Fa.json" 342 | ); 343 | } 344 | 345 | #[test] 346 | fn test_content_compare_path() { 347 | let full_arg_path = content_compare_path( 348 | "foo", 349 | "bar", 350 | Revision::from(1), 351 | Revision::from(2), 352 | &Query::identity("/a.json").unwrap(), 353 | ); 354 | assert_eq!( 355 | full_arg_path, 356 | "/api/v1/projects/foo/repos/bar/compare?path=%2Fa.json&from=1&to=2" 357 | ); 358 | 359 | let omitted_from_path = content_compare_path( 360 | "foo", 361 | "bar", 362 | Revision::DEFAULT, 363 | Revision::from(2), 364 | &Query::identity("/a.json").unwrap(), 365 | ); 366 | assert_eq!( 367 | omitted_from_path, 368 | "/api/v1/projects/foo/repos/bar/compare?path=%2Fa.json&to=2" 369 | ); 370 | 371 | let omitted_to_path = content_compare_path( 372 | "foo", 373 | "bar", 374 | Revision::from(1), 375 | Revision::DEFAULT, 376 | &Query::identity("/a.json").unwrap(), 377 | ); 378 | assert_eq!( 379 | omitted_to_path, 380 | "/api/v1/projects/foo/repos/bar/compare?path=%2Fa.json&from=1" 381 | ); 382 | 383 | let omitted_all_path = content_compare_path( 384 | "foo", 385 | "bar", 386 | Revision::DEFAULT, 387 | Revision::DEFAULT, 388 | &Query::identity("/a.json").unwrap(), 389 | ); 390 | assert_eq!( 391 | omitted_all_path, 392 | "/api/v1/projects/foo/repos/bar/compare?path=%2Fa.json" 393 | ); 394 | 395 | let with_json_query = content_compare_path( 396 | "foo", 397 | "bar", 398 | Revision::DEFAULT, 399 | Revision::DEFAULT, 400 | &Query::of_json_path("/a.json", vec!["a".to_string()]).unwrap(), 401 | ); 402 | assert_eq!( 403 | with_json_query, 404 | "/api/v1/projects/foo/repos/bar/compare?path=%2Fa.json&jsonpath=a" 405 | ); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/services/project.rs: -------------------------------------------------------------------------------- 1 | //! Project-related APIs 2 | use crate::{ 3 | client::{Client, Error}, 4 | model::Project, 5 | services::{path, status_unwrap}, 6 | }; 7 | 8 | use async_trait::async_trait; 9 | use reqwest::{Body, Method}; 10 | use serde::{Deserialize, Serialize}; 11 | use serde_json::json; 12 | 13 | /// Project-related APIs 14 | #[async_trait] 15 | pub trait ProjectService { 16 | /// Creates a project. 17 | async fn create_project(&self, name: &str) -> Result; 18 | 19 | /// Removes a project. A removed project can be [unremoved](#tymethod.unremove_project). 20 | async fn remove_project(&self, name: &str) -> Result<(), Error>; 21 | 22 | /// Purges a project that was removed before. 23 | async fn purge_project(&self, name: &str) -> Result<(), Error>; 24 | 25 | /// Unremoves a project. 26 | async fn unremove_project(&self, name: &str) -> Result; 27 | 28 | /// Retrieves the list of the projects. 29 | async fn list_projects(&self) -> Result, Error>; 30 | 31 | /// Retrieves the list of the removed projects, 32 | /// which can be [unremoved](#tymethod.unremove_project) 33 | /// or [purged](#tymethod.purge_project). 34 | async fn list_removed_projects(&self) -> Result, Error>; 35 | } 36 | 37 | #[async_trait] 38 | impl ProjectService for Client { 39 | async fn create_project(&self, name: &str) -> Result { 40 | #[derive(Serialize)] 41 | struct CreateProject<'a> { 42 | name: &'a str, 43 | } 44 | 45 | let body: Vec = serde_json::to_vec(&CreateProject { name })?; 46 | let body = Body::from(body); 47 | let req = self.new_request(Method::POST, path::projects_path(), Some(body))?; 48 | 49 | let resp = self.request(req).await?; 50 | let ok_resp = status_unwrap(resp).await?; 51 | let result = ok_resp.json().await?; 52 | 53 | Ok(result) 54 | } 55 | 56 | async fn remove_project(&self, name: &str) -> Result<(), Error> { 57 | let req = self.new_request(Method::DELETE, path::project_path(name), None)?; 58 | 59 | let resp = self.request(req).await?; 60 | let _ = status_unwrap(resp).await?; 61 | 62 | Ok(()) 63 | } 64 | 65 | async fn purge_project(&self, name: &str) -> Result<(), Error> { 66 | let req = self.new_request(Method::DELETE, path::removed_project_path(name), None)?; 67 | 68 | let resp = self.request(req).await?; 69 | let _ = status_unwrap(resp).await?; 70 | 71 | Ok(()) 72 | } 73 | 74 | async fn unremove_project(&self, name: &str) -> Result { 75 | let body: Vec = serde_json::to_vec(&json!([ 76 | {"op":"replace", "path":"/status", "value":"active"} 77 | ]))?; 78 | let body = Body::from(body); 79 | let req = self.new_request(Method::PATCH, path::project_path(name), Some(body))?; 80 | 81 | let resp = self.request(req).await?; 82 | let ok_resp = status_unwrap(resp).await?; 83 | let result = ok_resp.json().await?; 84 | 85 | Ok(result) 86 | } 87 | 88 | async fn list_projects(&self) -> Result, Error> { 89 | let req = self.new_request(Method::GET, path::projects_path(), None)?; 90 | let resp = self.request(req).await?; 91 | let ok_resp = status_unwrap(resp).await?; 92 | 93 | if let Some(0) = ok_resp.content_length() { 94 | return Ok(Vec::new()); 95 | } 96 | let result = ok_resp.json().await?; 97 | 98 | Ok(result) 99 | } 100 | 101 | async fn list_removed_projects(&self) -> Result, Error> { 102 | #[derive(Deserialize)] 103 | struct RemovedProject { 104 | name: String, 105 | } 106 | let req = self.new_request(Method::GET, path::removed_projects_path(), None)?; 107 | let resp = self.request(req).await?; 108 | let ok_resp = status_unwrap(resp).await?; 109 | 110 | let result: Vec = ok_resp.json().await?; 111 | let result = result.into_iter().map(|p| p.name).collect(); 112 | 113 | Ok(result) 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod test { 119 | use super::*; 120 | use wiremock::{ 121 | matchers::{body_json, header, method, path, query_param}, 122 | Mock, MockServer, ResponseTemplate, 123 | }; 124 | 125 | #[tokio::test] 126 | async fn test_list_projects() { 127 | let server = MockServer::start().await; 128 | let resp = ResponseTemplate::new(200).set_body_raw( 129 | r#"[{ 130 | "name":"foo", 131 | "creator":{"name":"minux", "email":"minux@m.x"}, 132 | "url":"/api/v1/projects/foo" 133 | }, { 134 | "name":"bar", 135 | "creator":{"name":"minux", "email":"minux@m.x"}, 136 | "url":"/api/v1/projects/bar" 137 | }]"#, 138 | "application/json", 139 | ); 140 | Mock::given(method("GET")) 141 | .and(path("/api/v1/projects")) 142 | .and(header("Authorization", "Bearer anonymous")) 143 | .respond_with(resp) 144 | .expect(1) 145 | .mount(&server) 146 | .await; 147 | 148 | let client = Client::new(&server.uri(), None).await.unwrap(); 149 | let projects = client.list_projects().await.unwrap(); 150 | 151 | drop(server); 152 | let expected = [ 153 | ("foo", "minux", "minux@m.x", "/api/v1/projects/foo"), 154 | ("bar", "minux", "minux@m.x", "/api/v1/projects/bar"), 155 | ]; 156 | 157 | for (p, e) in projects.iter().zip(expected.iter()) { 158 | assert_eq!(p.name, e.0); 159 | assert_eq!(p.creator.name, e.1); 160 | assert_eq!(p.creator.email, e.2); 161 | assert_eq!(p.url.as_ref().unwrap(), e.3); 162 | } 163 | } 164 | 165 | #[tokio::test] 166 | async fn test_list_removed_projects() { 167 | let server = MockServer::start().await; 168 | let resp = ResponseTemplate::new(200).set_body_raw( 169 | r#"[ 170 | {"name":"foo"}, 171 | {"name":"bar"} 172 | ]"#, 173 | "application/json", 174 | ); 175 | Mock::given(method("GET")) 176 | .and(path("/api/v1/projects")) 177 | .and(query_param("status", "removed")) 178 | .and(header("Authorization", "Bearer anonymous")) 179 | .respond_with(resp) 180 | .expect(1) 181 | .mount(&server) 182 | .await; 183 | 184 | let client = Client::new(&server.uri(), None).await.unwrap(); 185 | let projects = client.list_removed_projects().await.unwrap(); 186 | 187 | drop(server); 188 | assert_eq!(projects.len(), 2); 189 | 190 | assert_eq!(projects[0], "foo"); 191 | assert_eq!(projects[1], "bar"); 192 | } 193 | 194 | #[tokio::test] 195 | async fn test_create_project() { 196 | let server = MockServer::start().await; 197 | let project_json = serde_json::json!({"name": "foo"}); 198 | let resp = ResponseTemplate::new(201).set_body_raw( 199 | r#"{ 200 | "name":"foo", 201 | "creator":{"name":"minux", "email":"minux@m.x"} 202 | }"#, 203 | "application/json", 204 | ); 205 | Mock::given(method("POST")) 206 | .and(path("/api/v1/projects")) 207 | .and(header("Authorization", "Bearer anonymous")) 208 | .and(body_json(project_json)) 209 | .respond_with(resp) 210 | .expect(1) 211 | .mount(&server) 212 | .await; 213 | let client = Client::new(&server.uri(), None).await.unwrap(); 214 | let project = client.create_project("foo").await.unwrap(); 215 | 216 | drop(server); 217 | 218 | assert_eq!(project.name, "foo"); 219 | assert_eq!(project.creator.name, "minux"); 220 | assert_eq!(project.creator.email, "minux@m.x"); 221 | } 222 | 223 | #[tokio::test] 224 | async fn test_remove_project() { 225 | let server = MockServer::start().await; 226 | let resp = ResponseTemplate::new(204); 227 | Mock::given(method("DELETE")) 228 | .and(path("/api/v1/projects/foo")) 229 | .and(header("Authorization", "Bearer anonymous")) 230 | .respond_with(resp) 231 | .expect(1) 232 | .mount(&server) 233 | .await; 234 | 235 | let client = Client::new(&server.uri(), None).await.unwrap(); 236 | client.remove_project("foo").await.unwrap(); 237 | } 238 | 239 | #[tokio::test] 240 | async fn test_purge_project() { 241 | let server = MockServer::start().await; 242 | let resp = ResponseTemplate::new(204); 243 | Mock::given(method("DELETE")) 244 | .and(path("/api/v1/projects/foo/removed")) 245 | .and(header("Authorization", "Bearer anonymous")) 246 | .respond_with(resp) 247 | .expect(1) 248 | .mount(&server) 249 | .await; 250 | 251 | let client = Client::new(&server.uri(), None).await.unwrap(); 252 | client.purge_project("foo").await.unwrap(); 253 | } 254 | 255 | #[tokio::test] 256 | async fn test_unremove_project() { 257 | let server = MockServer::start().await; 258 | let unremove_json = 259 | serde_json::json!([{"op": "replace", "path": "/status", "value": "active"}]); 260 | let resp = ResponseTemplate::new(201).set_body_raw( 261 | r#"{ 262 | "name":"foo", 263 | "creator":{"name":"minux", "email":"minux@m.x"}, 264 | "url":"/api/v1/projects/foo" 265 | }"#, 266 | "application/json", 267 | ); 268 | Mock::given(method("PATCH")) 269 | .and(path("/api/v1/projects/foo")) 270 | .and(header("Content-Type", "application/json-patch+json")) 271 | .and(header("Authorization", "Bearer anonymous")) 272 | .and(body_json(unremove_json)) 273 | .respond_with(resp) 274 | .expect(1) 275 | .mount(&server) 276 | .await; 277 | 278 | let client = Client::new(&server.uri(), None).await.unwrap(); 279 | let project = client.unremove_project("foo").await.unwrap(); 280 | 281 | drop(server); 282 | 283 | assert_eq!(project.name, "foo"); 284 | assert_eq!(project.creator.name, "minux"); 285 | assert_eq!(project.creator.email, "minux@m.x"); 286 | assert_eq!(project.url.as_ref().unwrap(), "/api/v1/projects/foo"); 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/services/repository.rs: -------------------------------------------------------------------------------- 1 | //! Repository-related APIs 2 | use crate::{ 3 | client::{Error, ProjectClient}, 4 | model::Repository, 5 | services::{path, status_unwrap}, 6 | }; 7 | 8 | use async_trait::async_trait; 9 | use reqwest::{Body, Method}; 10 | use serde::{Deserialize, Serialize}; 11 | use serde_json::json; 12 | 13 | /// Repository-related APIs 14 | #[async_trait] 15 | pub trait RepoService { 16 | /// Creates a repository. 17 | async fn create_repo(&self, repo_name: &str) -> Result; 18 | 19 | /// Removes a repository, removed repository can be 20 | /// [unremoved](#tymethod.unremove_repo). 21 | async fn remove_repo(&self, repo_name: &str) -> Result<(), Error>; 22 | 23 | /// Purges a repository that was removed before. 24 | async fn purge_repo(&self, repo_name: &str) -> Result<(), Error>; 25 | 26 | /// Unremoves a repository. 27 | async fn unremove_repo(&self, repo_name: &str) -> Result; 28 | 29 | /// Retrieves the list of the repositories. 30 | async fn list_repos(&self) -> Result, Error>; 31 | 32 | /// Retrieves the list of the removed repositories, which can be 33 | /// [unremoved](#tymethod.unremove_repo). 34 | async fn list_removed_repos(&self) -> Result, Error>; 35 | } 36 | 37 | #[async_trait] 38 | impl<'a> RepoService for ProjectClient<'a> { 39 | async fn create_repo(&self, repo_name: &str) -> Result { 40 | #[derive(Serialize)] 41 | struct CreateRepo<'a> { 42 | name: &'a str, 43 | } 44 | 45 | let body = serde_json::to_vec(&CreateRepo { name: repo_name })?; 46 | let body = Body::from(body); 47 | 48 | let req = 49 | self.client 50 | .new_request(Method::POST, path::repos_path(self.project), Some(body))?; 51 | 52 | let resp = self.client.request(req).await?; 53 | let resp_body = status_unwrap(resp).await?.bytes().await?; 54 | let result = serde_json::from_slice(&resp_body[..])?; 55 | 56 | Ok(result) 57 | } 58 | 59 | async fn remove_repo(&self, repo_name: &str) -> Result<(), Error> { 60 | let req = self.client.new_request( 61 | Method::DELETE, 62 | path::repo_path(self.project, repo_name), 63 | None, 64 | )?; 65 | 66 | let resp = self.client.request(req).await?; 67 | let _ = status_unwrap(resp).await?; 68 | 69 | Ok(()) 70 | } 71 | 72 | async fn purge_repo(&self, repo_name: &str) -> Result<(), Error> { 73 | let req = self.client.new_request( 74 | Method::DELETE, 75 | path::removed_repo_path(self.project, repo_name), 76 | None, 77 | )?; 78 | 79 | let resp = self.client.request(req).await?; 80 | let _ = status_unwrap(resp).await?; 81 | 82 | Ok(()) 83 | } 84 | 85 | async fn unremove_repo(&self, repo_name: &str) -> Result { 86 | let body: Vec = serde_json::to_vec(&json!([ 87 | {"op":"replace", "path":"/status", "value":"active"} 88 | ]))?; 89 | let body = Body::from(body); 90 | let req = self.client.new_request( 91 | Method::PATCH, 92 | path::repo_path(self.project, repo_name), 93 | Some(body), 94 | )?; 95 | 96 | let resp = self.client.request(req).await?; 97 | let ok_resp = status_unwrap(resp).await?; 98 | let result = ok_resp.json().await?; 99 | 100 | Ok(result) 101 | } 102 | 103 | async fn list_repos(&self) -> Result, Error> { 104 | let req = self 105 | .client 106 | .new_request(Method::GET, path::repos_path(self.project), None)?; 107 | 108 | let resp = self.client.request(req).await?; 109 | let ok_resp = status_unwrap(resp).await?; 110 | let result = ok_resp.json().await?; 111 | 112 | Ok(result) 113 | } 114 | 115 | async fn list_removed_repos(&self) -> Result, Error> { 116 | #[derive(Deserialize)] 117 | struct RemovedRepo { 118 | name: String, 119 | } 120 | let req = 121 | self.client 122 | .new_request(Method::GET, path::removed_repos_path(self.project), None)?; 123 | 124 | let resp = self.client.request(req).await?; 125 | let ok_resp = status_unwrap(resp).await?; 126 | if ok_resp.status().as_u16() == 204 { 127 | return Ok(Vec::new()); 128 | } 129 | let result: Vec = ok_resp.json().await?; 130 | let result = result.into_iter().map(|r| r.name).collect(); 131 | 132 | Ok(result) 133 | } 134 | } 135 | 136 | #[cfg(test)] 137 | mod test { 138 | use super::*; 139 | use crate::{ 140 | model::{Author, Revision}, 141 | Client, 142 | }; 143 | use wiremock::{ 144 | matchers::{body_json, header, method, path, query_param}, 145 | Mock, MockServer, ResponseTemplate, 146 | }; 147 | 148 | #[tokio::test] 149 | async fn test_list_repos() { 150 | let server = MockServer::start().await; 151 | let resp = ResponseTemplate::new(200).set_body_raw( 152 | r#"[{ 153 | "name":"bar", 154 | "creator":{"name":"minux", "email":"minux@m.x"}, 155 | "url":"/api/v1/projects/foo/repos/bar", 156 | "createdAt":"a", 157 | "headRevision":2 158 | },{ 159 | "name":"baz", 160 | "creator":{"name":"minux", "email":"minux@m.x"}, 161 | "url":"/api/v1/projects/foo/repos/baz", 162 | "createdAt":"a", 163 | "headRevision":3 164 | }]"#, 165 | "application/json", 166 | ); 167 | Mock::given(method("GET")) 168 | .and(path("/api/v1/projects/foo/repos")) 169 | .and(header("Authorization", "Bearer anonymous")) 170 | .respond_with(resp) 171 | .mount(&server) 172 | .await; 173 | 174 | let client = Client::new(&server.uri(), None).await.unwrap(); 175 | let repos = client.project("foo").list_repos().await.unwrap(); 176 | 177 | let expected = [ 178 | ( 179 | "bar", 180 | Author { 181 | name: "minux".to_string(), 182 | email: "minux@m.x".to_string(), 183 | }, 184 | "/api/v1/projects/foo/repos/bar", 185 | Revision::from(2), 186 | ), 187 | ( 188 | "baz", 189 | Author { 190 | name: "minux".to_string(), 191 | email: "minux@m.x".to_string(), 192 | }, 193 | "/api/v1/projects/foo/repos/baz", 194 | Revision::from(3), 195 | ), 196 | ]; 197 | 198 | for (r, e) in repos.iter().zip(expected.iter()) { 199 | assert_eq!(r.name, e.0); 200 | assert_eq!(r.creator, e.1); 201 | assert_eq!(r.url.as_ref().unwrap(), &e.2); 202 | assert_eq!(r.head_revision, e.3); 203 | } 204 | } 205 | 206 | #[tokio::test] 207 | async fn test_list_removed_repos() { 208 | let server = MockServer::start().await; 209 | let resp = ResponseTemplate::new(200) 210 | .set_body_raw(r#"[{"name":"bar"}, {"name":"baz"}]"#, "application/json"); 211 | Mock::given(method("GET")) 212 | .and(path("/api/v1/projects/foo/repos")) 213 | .and(query_param("status", "removed")) 214 | .and(header("Authorization", "Bearer anonymous")) 215 | .respond_with(resp) 216 | .mount(&server) 217 | .await; 218 | 219 | let client = Client::new(&server.uri(), None).await.unwrap(); 220 | let repos = client.project("foo").list_removed_repos().await.unwrap(); 221 | 222 | assert_eq!(repos.len(), 2); 223 | assert_eq!(repos[0], "bar"); 224 | assert_eq!(repos[1], "baz"); 225 | } 226 | 227 | #[tokio::test] 228 | async fn test_create_repos() { 229 | let server = MockServer::start().await; 230 | let resp = r#"{"name":"bar", 231 | "creator":{"name":"minux", "email":"minux@m.x"}, 232 | "createdAt":"a", 233 | "headRevision": 2}"#; 234 | let resp = ResponseTemplate::new(201).set_body_raw(resp, "application/json"); 235 | 236 | let repo_json = serde_json::json!({"name": "bar"}); 237 | Mock::given(method("POST")) 238 | .and(path("/api/v1/projects/foo/repos")) 239 | .and(body_json(repo_json)) 240 | .and(header("Authorization", "Bearer anonymous")) 241 | .respond_with(resp) 242 | .mount(&server) 243 | .await; 244 | 245 | let client = Client::new(&server.uri(), None).await.unwrap(); 246 | let repo = client.project("foo").create_repo("bar").await.unwrap(); 247 | 248 | assert_eq!(repo.name, "bar"); 249 | assert_eq!( 250 | repo.creator, 251 | Author { 252 | name: "minux".to_string(), 253 | email: "minux@m.x".to_string() 254 | } 255 | ); 256 | assert_eq!(repo.head_revision, Revision::from(2)); 257 | } 258 | 259 | #[tokio::test] 260 | async fn test_remove_repos() { 261 | let server = MockServer::start().await; 262 | let resp = ResponseTemplate::new(204); 263 | 264 | Mock::given(method("DELETE")) 265 | .and(path("/api/v1/projects/foo/repos/bar")) 266 | .and(header("Authorization", "Bearer anonymous")) 267 | .respond_with(resp) 268 | .mount(&server) 269 | .await; 270 | 271 | let client = Client::new(&server.uri(), None).await.unwrap(); 272 | client.project("foo").remove_repo("bar").await.unwrap(); 273 | } 274 | 275 | #[tokio::test] 276 | async fn test_purge_repos() { 277 | let server = MockServer::start().await; 278 | let resp = ResponseTemplate::new(204); 279 | 280 | Mock::given(method("DELETE")) 281 | .and(path("/api/v1/projects/foo/repos/bar/removed")) 282 | .and(header("Authorization", "Bearer anonymous")) 283 | .respond_with(resp) 284 | .mount(&server) 285 | .await; 286 | 287 | let client = Client::new(&server.uri(), None).await.unwrap(); 288 | client.project("foo").purge_repo("bar").await.unwrap(); 289 | } 290 | 291 | #[tokio::test] 292 | async fn test_unremove_repos() { 293 | let server = MockServer::start().await; 294 | let resp = r#"{"name":"bar", 295 | "creator":{"name":"minux", "email":"minux@m.x"}, 296 | "createdAt":"a", 297 | "url":"/api/v1/projects/foo/repos/bar", 298 | "headRevision": 2}"#; 299 | let resp = ResponseTemplate::new(200).set_body_raw(resp, "application/json"); 300 | let unremove_json = serde_json::json!( 301 | [{"op": "replace", "path": "/status", "value": "active"}] 302 | ); 303 | Mock::given(method("PATCH")) 304 | .and(path("/api/v1/projects/foo/repos/bar")) 305 | .and(body_json(unremove_json)) 306 | .and(header("Authorization", "Bearer anonymous")) 307 | .and(header("Content-Type", "application/json-patch+json")) 308 | .respond_with(resp) 309 | .mount(&server) 310 | .await; 311 | 312 | let client = Client::new(&server.uri(), None).await.unwrap(); 313 | let repo = client.project("foo").unremove_repo("bar").await; 314 | 315 | let repo = repo.unwrap(); 316 | assert_eq!(repo.name, "bar"); 317 | assert_eq!( 318 | repo.creator, 319 | Author { 320 | name: "minux".to_string(), 321 | email: "minux@m.x".to_string() 322 | } 323 | ); 324 | assert_eq!(repo.head_revision, Revision::from(2)); 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/services/watch.rs: -------------------------------------------------------------------------------- 1 | //! Watch-related APIs 2 | use std::{pin::Pin, time::Duration}; 3 | 4 | use crate::{ 5 | model::{Query, Revision, WatchFileResult, WatchRepoResult, Watchable}, 6 | services::{path, status_unwrap}, 7 | Client, Error, RepoClient, 8 | }; 9 | 10 | use futures::{Stream, StreamExt}; 11 | use reqwest::{Method, Request, StatusCode}; 12 | 13 | const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); 14 | const DELAY_ON_SUCCESS: Duration = Duration::from_secs(1); 15 | const JITTER_RATE: f32 = 0.2; 16 | const MAX_BASE_TIME_MS: usize = 10_000; // 10sec = 10_000millis 17 | 18 | async fn request_watch(client: &Client, req: Request) -> Result, Error> { 19 | let resp = client.request(req).await?; 20 | if resp.status() == StatusCode::NOT_MODIFIED { 21 | return Ok(None); 22 | } 23 | let ok_resp = status_unwrap(resp).await?; 24 | let result = ok_resp.json().await?; 25 | 26 | Ok(Some(result)) 27 | } 28 | 29 | fn delay_time_for(failed_count: usize) -> Duration { 30 | let base_time_ms = MAX_BASE_TIME_MS.min(failed_count * 1000); 31 | let jitter = (fastrand::f32() * JITTER_RATE * base_time_ms as f32) as u64; 32 | Duration::from_millis(base_time_ms as u64 + jitter) 33 | } 34 | 35 | struct WatchState { 36 | client: Client, 37 | path: String, 38 | last_known_revision: Option, 39 | failed_count: usize, 40 | success_delay: Option, 41 | } 42 | 43 | fn watch_stream(client: Client, path: String) -> impl Stream + Send { 44 | let init_state = WatchState { 45 | client, 46 | path, 47 | last_known_revision: None, 48 | failed_count: 0, 49 | success_delay: None, 50 | }; 51 | futures::stream::unfold(init_state, |mut state| async move { 52 | if let Some(d) = state.success_delay.take() { 53 | tokio::time::sleep(d).await; 54 | } 55 | 56 | loop { 57 | let req = match state.client.new_watch_request( 58 | Method::GET, 59 | &state.path, 60 | None, 61 | state.last_known_revision, 62 | DEFAULT_TIMEOUT, 63 | ) { 64 | Ok(r) => r, 65 | Err(_) => { 66 | return None; 67 | } 68 | }; 69 | 70 | let resp: Result, _> = request_watch(&state.client, req).await; 71 | 72 | // handle response and decide next polling, we don't want to abuse CentralDogma server 73 | let next_delay = match resp { 74 | // Send Ok data out 75 | Ok(Some(watch_result)) => { 76 | state.last_known_revision = Some(watch_result.revision()); 77 | state.failed_count = 0; // reset fail count 78 | state.success_delay = Some(DELAY_ON_SUCCESS); 79 | 80 | return Some((watch_result, state)); 81 | } 82 | Ok(None) => { 83 | state.failed_count = 0; // reset fail count 84 | Duration::from_secs(1) 85 | } 86 | Err(Error::HttpClient(e)) if e.is_timeout() => Duration::from_secs(1), 87 | Err(e) => { 88 | log::debug!("Request error: {}", e); 89 | state.failed_count += 1; 90 | delay_time_for(state.failed_count) 91 | } 92 | }; 93 | 94 | // Delay 95 | tokio::time::sleep(next_delay).await; 96 | } 97 | }) 98 | } 99 | 100 | /// Watch-related APIs 101 | pub trait WatchService { 102 | /// Returns a stream which output a [`WatchFileResult`] when the result of the 103 | /// given [`Query`] becomes available or changes 104 | fn watch_file_stream( 105 | &self, 106 | query: &Query, 107 | ) -> Result + Send>>, Error>; 108 | 109 | /// Returns a stream which output a [`WatchRepoResult`] when the repository has a new commit 110 | /// that contains the changes for the files matched by the given `path_pattern`. 111 | fn watch_repo_stream( 112 | &self, 113 | path_pattern: &str, 114 | ) -> Result + Send>>, Error>; 115 | } 116 | 117 | impl<'a> WatchService for RepoClient<'a> { 118 | fn watch_file_stream( 119 | &self, 120 | query: &Query, 121 | ) -> Result + Send>>, Error> { 122 | let p = path::content_watch_path(self.project, self.repo, query); 123 | 124 | Ok(watch_stream(self.client.clone(), p).boxed()) 125 | } 126 | 127 | fn watch_repo_stream( 128 | &self, 129 | path_pattern: &str, 130 | ) -> Result + Send>>, Error> { 131 | let p = path::repo_watch_path(self.project, self.repo, path_pattern); 132 | 133 | Ok(watch_stream(self.client.clone(), p).boxed()) 134 | } 135 | } 136 | 137 | #[cfg(test)] 138 | mod test { 139 | use std::sync::atomic::{AtomicBool, Ordering}; 140 | 141 | use super::*; 142 | use crate::model::{Entry, EntryContent}; 143 | use wiremock::{ 144 | matchers::{header, method, path}, 145 | Mock, MockServer, Respond, ResponseTemplate, 146 | }; 147 | 148 | struct MockResponse { 149 | first_time: AtomicBool, 150 | } 151 | 152 | impl Respond for MockResponse { 153 | fn respond(&self, _req: &wiremock::Request) -> ResponseTemplate { 154 | if self.first_time.swap(false, Ordering::SeqCst) { 155 | println!("Called 1"); 156 | ResponseTemplate::new(304).set_delay(Duration::from_millis(100)) 157 | } else { 158 | println!("Called 2"); 159 | let resp = r#"{ 160 | "revision":3, 161 | "entry":{ 162 | "path":"/a.json", 163 | "type":"JSON", 164 | "content": {"a":"b"}, 165 | "revision":3, 166 | "url": "/api/v1/projects/foo/repos/bar/contents/a.json" 167 | } 168 | }"#; 169 | ResponseTemplate::new(200) 170 | .set_delay(Duration::from_millis(100)) 171 | .set_body_raw(resp, "application/json") 172 | } 173 | } 174 | } 175 | 176 | #[tokio::test] 177 | async fn test_watch_file() { 178 | let server = MockServer::start().await; 179 | let resp = MockResponse { 180 | first_time: AtomicBool::new(true), 181 | }; 182 | 183 | Mock::given(method("GET")) 184 | .and(path("/api/v1/projects/foo/repos/bar/contents/a.json")) 185 | .and(header("if-none-match", "-1")) 186 | .and(header("prefer", "wait=60")) 187 | .and(header("Authorization", "Bearer anonymous")) 188 | .respond_with(resp) 189 | .expect(2) 190 | .mount(&server) 191 | .await; 192 | 193 | let client = Client::new(&server.uri(), None).await.unwrap(); 194 | let stream = client 195 | .repo("foo", "bar") 196 | .watch_file_stream(&Query::identity("/a.json").unwrap()) 197 | .unwrap() 198 | .take_until(tokio::time::sleep(Duration::from_secs(3))); 199 | tokio::pin!(stream); 200 | 201 | let result = stream.next().await; 202 | 203 | server.reset().await; 204 | let result = result.unwrap(); 205 | assert_eq!(result.revision, Revision::from(3)); 206 | assert_eq!( 207 | result.entry, 208 | Entry { 209 | path: "/a.json".to_string(), 210 | content: EntryContent::Json(serde_json::json!({"a":"b"})), 211 | revision: Revision::from(3), 212 | url: "/api/v1/projects/foo/repos/bar/contents/a.json".to_string(), 213 | modified_at: None, 214 | } 215 | ); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/content.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | mod utils; 3 | 4 | use cd::{ 5 | model::{ 6 | Change, ChangeContent, CommitDetail, CommitMessage, Entry, EntryContent, Project, Query, 7 | Repository, Revision, 8 | }, 9 | ContentService, ProjectService, RepoService, 10 | }; 11 | use centraldogma as cd; 12 | 13 | use std::pin::Pin; 14 | 15 | use anyhow::{ensure, Context, Result}; 16 | use futures::future::{Future, FutureExt}; 17 | use serde_json::json; 18 | 19 | struct TestContext { 20 | client: cd::Client, 21 | project: Project, 22 | repo: Repository, 23 | } 24 | 25 | async fn run_test(test: T) 26 | where 27 | for<'a> T: FnOnce(&'a mut TestContext) -> Pin> + 'a>>, 28 | { 29 | let mut ctx = setup().await.expect("Failed to setup for test"); 30 | 31 | let result = test(&mut ctx).await; 32 | 33 | teardown(ctx).await.expect("Failed to teardown test setup"); 34 | 35 | result.unwrap(); 36 | } 37 | 38 | async fn setup() -> Result { 39 | let client = cd::Client::new("http://localhost:36462", None) 40 | .await 41 | .context("Failed to create client")?; 42 | let projects = client 43 | .list_projects() 44 | .await 45 | .context("Failed to list projects")?; 46 | assert_eq!(0, projects.len()); 47 | 48 | let prj_name = "TestProject"; 49 | let project = client 50 | .create_project(prj_name) 51 | .await 52 | .context("Failed to create new project")?; 53 | 54 | let repo_name = "TestRepo"; 55 | let repo = client 56 | .project(prj_name) 57 | .create_repo(repo_name) 58 | .await 59 | .context("Failed to create new repository")?; 60 | 61 | Ok(TestContext { 62 | client, 63 | project, 64 | repo, 65 | }) 66 | } 67 | 68 | async fn teardown(ctx: TestContext) -> Result<()> { 69 | ctx.client 70 | .project(&ctx.project.name) 71 | .remove_repo(&ctx.repo.name) 72 | .await 73 | .context("Failed to remove the repo")?; 74 | 75 | ctx.client 76 | .project(&ctx.project.name) 77 | .purge_repo(&ctx.repo.name) 78 | .await 79 | .context("Failed to remove the repo")?; 80 | 81 | ctx.client 82 | .remove_project(&ctx.project.name) 83 | .await 84 | .context("Failed to remove the project")?; 85 | 86 | ctx.client 87 | .purge_project(&ctx.project.name) 88 | .await 89 | .context("Failed to purge the project")?; 90 | 91 | Ok(()) 92 | } 93 | 94 | fn t<'a>(ctx: &'a mut TestContext) -> Pin> + 'a>> { 95 | async move { 96 | let r = ctx.client.repo(&ctx.project.name, &ctx.repo.name); 97 | 98 | // Push data 99 | let push_result = { 100 | let commit_msg = CommitMessage { 101 | summary: "New file".to_string(), 102 | detail: Some(CommitDetail::Plaintext("detail".to_string())), 103 | }; 104 | let changes = vec![Change { 105 | path: "/a.json".to_string(), 106 | content: ChangeContent::UpsertJson(json!({ 107 | "test_key": "test_value" 108 | })), 109 | }, Change { 110 | path: "/folder/b.txt".to_string(), 111 | content: ChangeContent::UpsertText("text value".to_string()), 112 | }]; 113 | 114 | r.push( 115 | Revision::HEAD, 116 | commit_msg, 117 | changes, 118 | ) 119 | .await 120 | .context(here!("Failed to push file"))? 121 | }; 122 | 123 | // Get single file 124 | { 125 | let file: Entry = r.get_file( 126 | push_result.revision, 127 | &Query::of_json("/a.json").unwrap(), 128 | ) 129 | .await 130 | .context(here!("Failed to fetch file content"))?; 131 | 132 | println!("File: {:?}", &file); 133 | ensure!( 134 | matches!(file.content, EntryContent::Json(json) if json == json!({"test_key": "test_value"})), 135 | here!("Expect same json content") 136 | ); 137 | } 138 | 139 | // List files 140 | { 141 | let file_list = r.list_files( 142 | Revision::HEAD, 143 | "" 144 | ) 145 | .await 146 | .context(here!("Failed to list files"))?; 147 | 148 | println!("File list: {:?}", &file_list); 149 | 150 | ensure!( 151 | file_list.len() == 3, 152 | here!("Wrong number of file entry returned") 153 | ); 154 | } 155 | 156 | // Get single file jsonpath 157 | { 158 | let file: Entry = r.get_file( 159 | push_result.revision, 160 | &Query::of_json_path("/a.json", vec!["test_key".to_owned()]).unwrap(), 161 | ) 162 | .await 163 | .context(here!("Failed to fetch file content"))?; 164 | println!("File: {:?}", &file); 165 | 166 | ensure!( 167 | matches!(file.content, EntryContent::Json(json) if json == json!("test_value")), 168 | here!("Expect same json content") 169 | ); 170 | } 171 | 172 | // Get history 173 | { 174 | let commits = r.get_history( 175 | Revision::INIT, 176 | Revision::HEAD, 177 | "/**", 178 | None 179 | ) 180 | .await 181 | .context(here!("Failed to get history"))?; 182 | 183 | println!("History: {:?}", &commits); 184 | } 185 | 186 | // Get history without from-to revision 187 | { 188 | let commits = r.get_history( 189 | Revision::DEFAULT, 190 | Revision::DEFAULT, 191 | "/**", 192 | None 193 | ) 194 | .await 195 | .context(here!("Failed to get history"))?; 196 | 197 | println!("History: {:?}", &commits); 198 | } 199 | 200 | // Get multiple files 201 | { 202 | let entries = r.get_files( 203 | push_result.revision, 204 | "a*" 205 | ) 206 | .await 207 | .context(here!("Failed to fetch multiple files"))?; 208 | ensure!(entries.len() == 1, here!("wrong number of entries returned")); 209 | 210 | let entries = r.get_files( 211 | push_result.revision, 212 | "*" 213 | ) 214 | .await 215 | .context(here!("Failed to fetch multiple files"))?; 216 | ensure!(entries.len() == 3, here!("wrong number of entries returned")); 217 | 218 | println!("Entries: {:?}", &entries); 219 | let exist = entries.iter().any(|e| { 220 | e.path == "/folder/b.txt" && matches!(&e.content, EntryContent::Text(s) if s == "text value\n") 221 | }); 222 | ensure!(exist, here!("Expected value not found")); 223 | } 224 | 225 | // Get file diff 226 | { 227 | let commit_msg = CommitMessage { 228 | summary: "Update a.json".to_string(), 229 | detail: None, 230 | }; 231 | let changes = vec![Change { 232 | path: "/a.json".to_string(), 233 | content: ChangeContent::ApplyJsonPatch(json!([ 234 | {"op": "replace", "path": "/test_key", "value": "updated_value"}, 235 | {"op": "add", "path": "/new_key", "value": ["new_array_item1", "new_array_item2"]} 236 | ])), 237 | }]; 238 | 239 | r.push( 240 | Revision::HEAD, 241 | commit_msg, 242 | changes, 243 | ) 244 | .await 245 | .context(here!("Failed to push file"))?; 246 | 247 | let diff = r.get_diff( 248 | Revision::from(1), 249 | Revision::HEAD, 250 | &Query::of_json("/a.json").unwrap(), 251 | ) 252 | .await 253 | .context(here!("Failed to get diff"))?; 254 | println!("Diff: {:?}", diff); 255 | 256 | ensure!(diff.path == "/a.json", here!("Diff path incorrect")); 257 | 258 | let expected_json = json!({ 259 | "new_key": ["new_array_item1", "new_array_item2"], 260 | "test_key": "updated_value" 261 | }); 262 | ensure!( 263 | matches!(diff.content, ChangeContent::UpsertJson(json) if json == expected_json), 264 | here!("Diff content incorrect") 265 | ); 266 | } 267 | 268 | // Get multiple file diff 269 | { 270 | let diffs = r.get_diffs( 271 | Revision::from(1), 272 | Revision::HEAD, 273 | "*" 274 | ) 275 | .await 276 | .context(here!("Failed to get diff"))?; 277 | 278 | ensure!(diffs.len() == 2, here!("Expect 2 diffs")); 279 | } 280 | 281 | Ok(()) 282 | } 283 | .boxed() 284 | } 285 | 286 | #[cfg(test)] 287 | #[tokio::test] 288 | async fn test_content() { 289 | run_test(t).await; 290 | } 291 | -------------------------------------------------------------------------------- /tests/projects.rs: -------------------------------------------------------------------------------- 1 | use cd::ProjectService; 2 | use centraldogma as cd; 3 | 4 | #[cfg(test)] 5 | #[tokio::test] 6 | async fn test_projects() { 7 | let client = cd::Client::new("http://localhost:36462", None) 8 | .await 9 | .unwrap(); 10 | let projects = client 11 | .list_projects() 12 | .await 13 | .expect("Failed to list projects"); 14 | assert_eq!(0, projects.len()); 15 | 16 | let invalid_prj_name = "Test Project"; 17 | let invalid_new_project = client.create_project(invalid_prj_name).await; 18 | assert!(matches!(invalid_new_project, Err(_))); 19 | 20 | let prj_name = "TestProject"; 21 | let new_project = client 22 | .create_project(prj_name) 23 | .await 24 | .expect("Failed to create new project"); 25 | assert_eq!(prj_name, new_project.name); 26 | 27 | let projects = client 28 | .list_projects() 29 | .await 30 | .expect("Failed to list projects"); 31 | assert_eq!(1, projects.len()); 32 | assert_eq!(prj_name, projects[0].name); 33 | 34 | client 35 | .remove_project(prj_name) 36 | .await 37 | .expect("Failed to remove the project"); 38 | 39 | let removed_projects = client 40 | .list_removed_projects() 41 | .await 42 | .expect("Failed to list removed projects"); 43 | assert_eq!(1, removed_projects.len()); 44 | assert_eq!(prj_name, removed_projects[0]); 45 | 46 | let unremove_project = client 47 | .unremove_project(prj_name) 48 | .await 49 | .expect("Failed to unremove project"); 50 | assert_eq!(prj_name, unremove_project.name); 51 | 52 | client 53 | .remove_project(prj_name) 54 | .await 55 | .expect("Failed to remove the project again"); 56 | 57 | client 58 | .purge_project(prj_name) 59 | .await 60 | .expect("Failed to purge the project"); 61 | } 62 | -------------------------------------------------------------------------------- /tests/repo.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | mod utils; 3 | use cd::{ProjectService, RepoService}; 4 | use centraldogma as cd; 5 | 6 | use anyhow::{ensure, Context, Result}; 7 | use futures::future::{Future, FutureExt}; 8 | use std::pin::Pin; 9 | 10 | struct TestContext { 11 | client: cd::Client, 12 | project: cd::model::Project, 13 | } 14 | 15 | async fn run_test(test: T) 16 | where 17 | for<'a> T: FnOnce(&'a mut TestContext) -> Pin> + 'a>>, 18 | { 19 | let mut ctx = setup().await.expect("Failed to setup for test"); 20 | 21 | let result = test(&mut ctx).await; 22 | 23 | teardown(ctx).await.expect("Failed to teardown test setup"); 24 | 25 | result.unwrap(); 26 | } 27 | 28 | async fn setup() -> Result { 29 | let client = cd::Client::new("http://localhost:36462", None) 30 | .await 31 | .context("Failed to create client")?; 32 | let projects = client 33 | .list_projects() 34 | .await 35 | .context("Failed to list projects")?; 36 | assert_eq!(0, projects.len()); 37 | 38 | let prj_name = "TestProject"; 39 | let project = client 40 | .create_project(prj_name) 41 | .await 42 | .context("Failed to create new project")?; 43 | 44 | Ok(TestContext { client, project }) 45 | } 46 | 47 | async fn teardown(ctx: TestContext) -> Result<()> { 48 | ctx.client 49 | .remove_project(&ctx.project.name) 50 | .await 51 | .context("Failed to remove the project")?; 52 | 53 | ctx.client 54 | .purge_project(&ctx.project.name) 55 | .await 56 | .context("Failed to purge the project")?; 57 | 58 | Ok(()) 59 | } 60 | 61 | fn t1<'a>(ctx: &'a mut TestContext) -> Pin> + 'a>> { 62 | async move { 63 | let r = ctx.client.project(&ctx.project.name); 64 | 65 | // List repositories 66 | let repos = r 67 | .list_repos() 68 | .await 69 | .context("Failed to list repositories from project")?; 70 | ensure!(repos.len() == 2, here!("New project should have 2 repos")); 71 | 72 | // Create new repository 73 | let repo_name = "TestRepo"; 74 | let new_repo = r 75 | .create_repo(repo_name) 76 | .await 77 | .context("Failed to create new Repository")?; 78 | ensure!(repo_name == new_repo.name, here!("Wrong repo name")); 79 | 80 | // Remove created repository 81 | r.remove_repo(repo_name) 82 | .await 83 | .context("Failed to remove Repository")?; 84 | 85 | let removed_repos = r 86 | .list_removed_repos() 87 | .await 88 | .context("Failed to list removed repositories")?; 89 | 90 | let mut found = false; 91 | for repo in removed_repos.iter() { 92 | if repo == repo_name { 93 | found = true; 94 | } 95 | } 96 | ensure!(found, here!("Removed repo not showed in removed repo list")); 97 | 98 | // Unremove removed repository 99 | let unremoved_repo = r 100 | .unremove_repo(repo_name) 101 | .await 102 | .context("Failed to unremove removed Repository")?; 103 | ensure!(unremoved_repo.name == repo_name, here!("Invalid unremove")); 104 | 105 | let repos = r 106 | .list_repos() 107 | .await 108 | .context("Failed to list repositories from project")?; 109 | 110 | let mut found = false; 111 | for repo in repos.iter() { 112 | if repo.name == repo_name { 113 | found = true; 114 | } 115 | } 116 | ensure!(found, here!("Unremoved repo not showed in repo list")); 117 | 118 | r.remove_repo(repo_name) 119 | .await 120 | .context("Failed to remove Repository")?; 121 | 122 | // Purge removed repository 123 | r.purge_repo(repo_name) 124 | .await 125 | .context("Failed to purge removed Repository")?; 126 | 127 | let removed_repos = r 128 | .list_removed_repos() 129 | .await 130 | .context("Failed to list removed repositories")?; 131 | 132 | let mut found = false; 133 | for repo in removed_repos.iter() { 134 | if repo == repo_name { 135 | found = true; 136 | } 137 | } 138 | ensure!(!found, here!("Purged repo showed in removed repo list")); 139 | 140 | Ok(()) 141 | } 142 | .boxed() 143 | } 144 | 145 | #[cfg(test)] 146 | #[tokio::test] 147 | async fn test_repos() { 148 | run_test(t1).await 149 | } 150 | -------------------------------------------------------------------------------- /tests/utils.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! here { 3 | ($e:expr) => { 4 | format!( 5 | "{}: {}", 6 | concat!("at ", file!(), ":", line!(), ":", column!()), 7 | $e 8 | ) 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /tests/watch.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | mod utils; 3 | 4 | use cd::{ 5 | model::{Change, ChangeContent, CommitMessage, EntryContent, Query, Revision}, 6 | ContentService, ProjectService, RepoService, WatchService, 7 | }; 8 | use centraldogma as cd; 9 | 10 | use std::{pin::Pin, time::Duration}; 11 | 12 | use anyhow::{ensure, Context, Result}; 13 | use futures::{ 14 | future::{Future, FutureExt}, 15 | StreamExt, 16 | }; 17 | use serde_json::json; 18 | 19 | struct TestContext { 20 | client: cd::Client, 21 | project: cd::model::Project, 22 | repo: cd::model::Repository, 23 | } 24 | 25 | async fn run_test(test: T) 26 | where 27 | for<'a> T: FnOnce(&'a mut TestContext) -> Pin> + 'a>>, 28 | { 29 | let mut ctx = setup().await.expect("Failed to setup for test"); 30 | 31 | let result = test(&mut ctx).await; 32 | 33 | teardown(ctx).await.expect("Failed to teardown test setup"); 34 | 35 | result.unwrap(); 36 | } 37 | 38 | async fn setup() -> Result { 39 | let client = cd::Client::new("http://localhost:36462", None) 40 | .await 41 | .context("Failed to create client")?; 42 | let projects = client 43 | .list_projects() 44 | .await 45 | .context("Failed to list projects")?; 46 | assert_eq!(0, projects.len()); 47 | 48 | let prj_name = "TestProject"; 49 | let project = client 50 | .create_project(prj_name) 51 | .await 52 | .context("Failed to create new project")?; 53 | 54 | let repo_name = "TestRepo"; 55 | let repo = client 56 | .project(prj_name) 57 | .create_repo(repo_name) 58 | .await 59 | .context("Failed to create new repository")?; 60 | 61 | Ok(TestContext { 62 | client, 63 | project, 64 | repo, 65 | }) 66 | } 67 | 68 | async fn teardown(ctx: TestContext) -> Result<()> { 69 | ctx.client 70 | .project(&ctx.project.name) 71 | .remove_repo(&ctx.repo.name) 72 | .await 73 | .context("Failed to remove the repo")?; 74 | 75 | ctx.client 76 | .project(&ctx.project.name) 77 | .purge_repo(&ctx.repo.name) 78 | .await 79 | .context("Failed to remove the repo")?; 80 | 81 | ctx.client 82 | .remove_project(&ctx.project.name) 83 | .await 84 | .context("Failed to remove the project")?; 85 | 86 | ctx.client 87 | .purge_project(&ctx.project.name) 88 | .await 89 | .context("Failed to purge the project")?; 90 | 91 | Ok(()) 92 | } 93 | 94 | fn watch_file_stream_test<'a>( 95 | ctx: &'a mut TestContext, 96 | ) -> Pin> + 'a>> { 97 | async move { 98 | let r = ctx.client.repo(&ctx.project.name, &ctx.repo.name); 99 | 100 | let commit_msg = CommitMessage { 101 | summary: "File".to_string(), 102 | detail: None, 103 | }; 104 | let file_change = vec![Change { 105 | path: "/a.json".to_string(), 106 | content: ChangeContent::UpsertJson(json!({"a": "b"})), 107 | }]; 108 | 109 | r.push(Revision::HEAD, commit_msg, file_change) 110 | .await 111 | .context(here!("Failed to push file"))?; 112 | 113 | let watch_stream = r 114 | .watch_file_stream(&Query::of_json("/a.json").unwrap()) 115 | .context(here!("Failed to get file watch stream"))?; 116 | 117 | let new_commit_msg = CommitMessage { 118 | summary: "change content".to_string(), 119 | detail: None, 120 | }; 121 | let new_change = vec![Change { 122 | path: "/a.json".to_string(), 123 | content: ChangeContent::UpsertJson(json!({"a": "c"})), 124 | }]; 125 | let new_push = async move { 126 | tokio::time::sleep(Duration::from_millis(100)).await; 127 | r.push(Revision::HEAD, new_commit_msg, new_change).await 128 | }; 129 | 130 | let sleep = tokio::time::sleep(Duration::from_millis(10000)); 131 | futures::pin_mut!(sleep); 132 | 133 | let mut s = watch_stream.take_until(sleep); 134 | let (wr, _) = tokio::join!(s.next(), new_push); 135 | 136 | println!("Watch result: {:?}", wr); 137 | ensure!(wr.is_some(), here!("Failed to get initial watch result")); 138 | let wr = wr.unwrap(); 139 | 140 | ensure!( 141 | wr.entry.path == "/a.json", 142 | here!("Wrong entry path returned") 143 | ); 144 | ensure!( 145 | matches!(wr.entry.content, EntryContent::Json(json) if json == json!({"a": "c"})), 146 | here!("Wrong entry content returned") 147 | ); 148 | 149 | Ok(()) 150 | } 151 | .boxed() 152 | } 153 | 154 | fn watch_repo_stream_test<'a>( 155 | ctx: &'a mut TestContext, 156 | ) -> Pin> + 'a>> { 157 | async move { 158 | let r = ctx.client.repo(&ctx.project.name, &ctx.repo.name); 159 | 160 | let watch_stream = r 161 | .watch_repo_stream("") 162 | .context(here!("Failed to get file watch stream"))?; 163 | 164 | let new_commit_msg = CommitMessage { 165 | summary: "change content".to_string(), 166 | detail: None, 167 | }; 168 | let new_change = vec![Change { 169 | path: "/a.json".to_string(), 170 | content: ChangeContent::UpsertJson(json!({"a": "c"})), 171 | }]; 172 | let new_push = async move { 173 | tokio::time::sleep(Duration::from_millis(100)).await; 174 | r.push(Revision::HEAD, new_commit_msg, new_change).await 175 | }; 176 | 177 | let sleep = tokio::time::sleep(Duration::from_millis(10000)); 178 | futures::pin_mut!(sleep); 179 | 180 | let mut s = watch_stream.take_until(sleep); 181 | let (wr, _) = tokio::join!(s.next(), new_push); 182 | 183 | println!("Watch result: {:?}", wr); 184 | ensure!(wr.is_some(), here!("Failed to get initial watch result")); 185 | 186 | Ok(()) 187 | } 188 | .boxed() 189 | } 190 | 191 | #[cfg(test)] 192 | #[tokio::test] 193 | async fn test_watch() { 194 | run_test(watch_file_stream_test).await; 195 | run_test(watch_repo_stream_test).await; 196 | } 197 | --------------------------------------------------------------------------------