├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── benches └── type_id.rs ├── fuzz ├── .gitignore ├── Cargo.lock ├── Cargo.toml └── fuzz_targets │ ├── static_typeid.rs │ └── typeid.rs ├── src ├── arbitrary.rs ├── lib.rs └── serde.rs └── tests ├── spec.rs └── spec ├── README.md ├── invalid.yml └── valid.yml /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Continuous integration 4 | 5 | jobs: 6 | check: 7 | name: Check 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions-rs/toolchain@v1 12 | with: 13 | profile: minimal 14 | toolchain: stable 15 | override: true 16 | - uses: actions-rs/cargo@v1 17 | with: 18 | command: check 19 | 20 | test: 21 | name: Test Suite 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions-rs/toolchain@v1 26 | with: 27 | profile: minimal 28 | toolchain: stable 29 | override: true 30 | - uses: actions-rs/cargo@v1 31 | with: 32 | command: test 33 | args: --all-features 34 | clippy: 35 | name: Clippy 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v3 39 | - uses: actions-rs/toolchain@v1 40 | with: 41 | profile: minimal 42 | toolchain: stable 43 | override: true 44 | - run: rustup component add clippy 45 | - uses: actions-rs/cargo@v1 46 | with: 47 | command: clippy 48 | args: -- -D warnings 49 | audit: 50 | name: Security Audit 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v3 54 | - uses: actions-rs/toolchain@v1 55 | with: 56 | profile: minimal 57 | toolchain: stable 58 | override: true 59 | - run: cargo install cargo-audit 60 | - uses: actions-rs/audit-check@v1.2.0 61 | with: 62 | token: ${{ secrets.GITHUB_TOKEN }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "type-safe-id" 3 | version = "0.3.2" 4 | edition = "2021" 5 | description = "A type-safe, K-sortable, globally unique identifier" 6 | license = "MIT OR Apache-2.0" 7 | repository = "https://github.com/conradludgate/type-safe-id" 8 | 9 | [features] 10 | default = [] 11 | arbitrary = ["dep:arbitrary"] 12 | serde = ["dep:serde"] 13 | 14 | [dependencies] 15 | uuid = { version = "1.6.0", features = ["v7"] } 16 | rand = { version = "0.8.0" } 17 | thiserror = "2.0.0" 18 | arrayvec = "0.7.0" 19 | 20 | serde = { version = "1.0.0", optional = true } 21 | arbitrary = { version = "1.0.0", optional = true } 22 | 23 | # Properly document all features on docs.rs 24 | [package.metadata.docs.rs] 25 | all-features = true 26 | rustdoc-args = ["--cfg", "docsrs"] 27 | 28 | [dev-dependencies] 29 | criterion = "0.5" 30 | libtest-mimic = "0.8" 31 | serde_yaml = "0.9" 32 | serde = { version = "1", features = ["derive"] } 33 | uuid = { version = "1", features = ["serde"] } 34 | 35 | [[bench]] 36 | name = "type_id" 37 | harness = false 38 | 39 | [[test]] 40 | name = "spec" 41 | path = "tests/spec.rs" 42 | harness = false 43 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Conrad Ludgate 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # type-safe-id 2 | 3 | A type-safe, K-sortable, globally unique identifier. 4 | 5 | Typed implementation of in Rust. 6 | 7 | # Examples 8 | 9 | ## StaticType prefixes 10 | 11 | This is the intended happy path. Using a StaticType implementation, you ensure 12 | that the ID being parsed is of the intended type. 13 | 14 | ```rust 15 | use type_safe_id::{StaticType, TypeSafeId}; 16 | 17 | #[derive(Default)] 18 | struct User; 19 | 20 | impl StaticType for User { 21 | // must be lowercase ascii [a-z] only 22 | const TYPE: &'static str = "user"; 23 | } 24 | 25 | // type alias for your custom typed id 26 | type UserId = TypeSafeId; 27 | 28 | let user_id1 = UserId::new(); 29 | # std::thread::sleep(std::time::Duration::from_millis(10)); 30 | let user_id2 = UserId::new(); 31 | 32 | let uid1 = user_id1.to_string(); 33 | let uid2 = user_id2.to_string(); 34 | dbg!(&uid1, &uid2); 35 | assert!(uid2 > uid1, "type safe IDs are ordered"); 36 | 37 | let user_id3: UserId = uid1.parse().expect("invalid user id"); 38 | let user_id4: UserId = uid2.parse().expect("invalid user id"); 39 | 40 | assert_eq!(user_id1.uuid(), user_id3.uuid(), "round trip works"); 41 | assert_eq!(user_id2.uuid(), user_id4.uuid(), "round trip works"); 42 | ``` 43 | 44 | ## DynamicType prefixes 45 | 46 | If you can't know what the prefix will be, you can use the DynamicType prefix. 47 | 48 | ```rust 49 | use type_safe_id::{DynamicType, TypeSafeId}; 50 | 51 | let id: TypeSafeId = "prefix_01h2xcejqtf2nbrexx3vqjhp41".parse().unwrap(); 52 | 53 | assert_eq!(id.type_prefix(), "prefix"); 54 | assert_eq!(id.uuid(), uuid::uuid!("0188bac7-4afa-78aa-bc3b-bd1eef28d881")); 55 | ``` 56 | 57 | # Help 58 | 59 | ```rust 60 | error[E0080]: evaluation of `::__TYPE_PREFIX_IS_VALID` failed 61 | --> /Users/conrad/Documents/code/type-safe-id/src/lib.rs:76:13 62 | | 63 | 76 | assert!(Self::TYPE.as_bytes()[i].is_ascii_lowercase()); 64 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the evaluated program panicked at 'assertion failed: Self::TYPE.as_bytes()[i].is_ascii_lowercase()', /Users/conrad/Documents/code/type-safe-id/src/lib.rs:76:13 65 | ``` 66 | 67 | This compiler error suggests that your static type prefix is not valid because it contains non ascii-lowercase values. 68 | 69 | ```rust 70 | error[E0080]: evaluation of `::__TYPE_PREFIX_IS_VALID` failed 71 | --> /Users/conrad/Documents/code/type-safe-id/src/lib.rs:73:9 72 | | 73 | 73 | assert!(Self::TYPE.len() < 64); 74 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the evaluated program panicked at 'assertion failed: Self::TYPE.len() < 64', /Users/conrad/Documents/code/type-safe-id/src/lib.rs:73:9 75 | | 76 | ``` 77 | 78 | This compiler error suggests that your static type prefix is not valid because it contains more than 63 characters. 79 | -------------------------------------------------------------------------------- /benches/type_id.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Write, str::FromStr}; 2 | 3 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 4 | use type_safe_id::{StaticType, TypeSafeId}; 5 | use uuid::{uuid, Uuid}; 6 | 7 | #[derive(Default)] 8 | struct Index; 9 | 10 | impl StaticType for Index { 11 | const TYPE: &'static str = "index"; 12 | } 13 | 14 | type IndexId = TypeSafeId; 15 | 16 | const UUID: Uuid = uuid!("0188bac7-4afa-78aa-bc3b-bd1eef28d881"); 17 | 18 | fn format() -> [u8; 32] { 19 | let id = IndexId::from_uuid(black_box(UUID)); 20 | 21 | let mut out = [0u8; 32]; 22 | out.as_mut().write_fmt(format_args!("{id}")).unwrap(); 23 | out 24 | } 25 | 26 | fn parse() -> IndexId { 27 | IndexId::from_str(black_box("index_01h2xcejqtf2nbrexx3vqjhp41")).unwrap() 28 | } 29 | 30 | pub fn criterion_benchmark(c: &mut Criterion) { 31 | c.bench_function("format", |b| b.iter(format)); 32 | c.bench_function("parse", |b| b.iter(parse)); 33 | } 34 | 35 | criterion_group!(benches, criterion_benchmark); 36 | criterion_main!(benches); 37 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /fuzz/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "arbitrary" 7 | version = "1.3.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "e2d098ff73c1ca148721f37baad5ea6a465a13f9573aba8641fbbbae8164a54e" 10 | 11 | [[package]] 12 | name = "arrayvec" 13 | version = "0.7.4" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" 16 | 17 | [[package]] 18 | name = "cc" 19 | version = "1.0.79" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" 22 | dependencies = [ 23 | "jobserver", 24 | ] 25 | 26 | [[package]] 27 | name = "cfg-if" 28 | version = "1.0.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 31 | 32 | [[package]] 33 | name = "getrandom" 34 | version = "0.2.10" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" 37 | dependencies = [ 38 | "cfg-if", 39 | "libc", 40 | "wasi", 41 | ] 42 | 43 | [[package]] 44 | name = "jobserver" 45 | version = "0.1.26" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" 48 | dependencies = [ 49 | "libc", 50 | ] 51 | 52 | [[package]] 53 | name = "libc" 54 | version = "0.2.147" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" 57 | 58 | [[package]] 59 | name = "libfuzzer-sys" 60 | version = "0.4.6" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "beb09950ae85a0a94b27676cccf37da5ff13f27076aa1adbc6545dd0d0e1bd4e" 63 | dependencies = [ 64 | "arbitrary", 65 | "cc", 66 | "once_cell", 67 | ] 68 | 69 | [[package]] 70 | name = "once_cell" 71 | version = "1.18.0" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" 74 | 75 | [[package]] 76 | name = "ppv-lite86" 77 | version = "0.2.17" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 80 | 81 | [[package]] 82 | name = "proc-macro2" 83 | version = "1.0.63" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" 86 | dependencies = [ 87 | "unicode-ident", 88 | ] 89 | 90 | [[package]] 91 | name = "quote" 92 | version = "1.0.29" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" 95 | dependencies = [ 96 | "proc-macro2", 97 | ] 98 | 99 | [[package]] 100 | name = "rand" 101 | version = "0.8.5" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 104 | dependencies = [ 105 | "libc", 106 | "rand_chacha", 107 | "rand_core", 108 | ] 109 | 110 | [[package]] 111 | name = "rand_chacha" 112 | version = "0.3.1" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 115 | dependencies = [ 116 | "ppv-lite86", 117 | "rand_core", 118 | ] 119 | 120 | [[package]] 121 | name = "rand_core" 122 | version = "0.6.4" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 125 | dependencies = [ 126 | "getrandom", 127 | ] 128 | 129 | [[package]] 130 | name = "syn" 131 | version = "2.0.22" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616" 134 | dependencies = [ 135 | "proc-macro2", 136 | "quote", 137 | "unicode-ident", 138 | ] 139 | 140 | [[package]] 141 | name = "thiserror" 142 | version = "1.0.40" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" 145 | dependencies = [ 146 | "thiserror-impl", 147 | ] 148 | 149 | [[package]] 150 | name = "thiserror-impl" 151 | version = "1.0.40" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" 154 | dependencies = [ 155 | "proc-macro2", 156 | "quote", 157 | "syn", 158 | ] 159 | 160 | [[package]] 161 | name = "type-safe-id" 162 | version = "0.2.1" 163 | dependencies = [ 164 | "arbitrary", 165 | "arrayvec", 166 | "rand", 167 | "thiserror", 168 | "uuid", 169 | ] 170 | 171 | [[package]] 172 | name = "type-safe-id-fuzz" 173 | version = "0.0.0" 174 | dependencies = [ 175 | "libfuzzer-sys", 176 | "type-safe-id", 177 | ] 178 | 179 | [[package]] 180 | name = "unicode-ident" 181 | version = "1.0.9" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" 184 | 185 | [[package]] 186 | name = "uuid" 187 | version = "1.4.0" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be" 190 | 191 | [[package]] 192 | name = "wasi" 193 | version = "0.11.0+wasi-snapshot-preview1" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 196 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "type-safe-id-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | 13 | [dependencies.type-safe-id] 14 | path = ".." 15 | features = ["arbitrary"] 16 | 17 | # Prevent this from interfering with workspaces 18 | [workspace] 19 | members = ["."] 20 | 21 | [profile.release] 22 | debug = 1 23 | 24 | [[bin]] 25 | name = "typeid" 26 | path = "fuzz_targets/typeid.rs" 27 | test = false 28 | doc = false 29 | 30 | [[bin]] 31 | name = "static_typeid" 32 | path = "fuzz_targets/static_typeid.rs" 33 | test = false 34 | doc = false 35 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/static_typeid.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | use type_safe_id::{TypeSafeId, StaticType}; 5 | 6 | fuzz_target!(|id: TypeSafeId| { 7 | let id2 = id.to_string().parse().unwrap(); 8 | assert_eq!(id, id2); 9 | }); 10 | 11 | #[derive(Default, Debug, PartialEq)] 12 | struct Type; 13 | impl StaticType for Type { 14 | const TYPE: &'static str = "type"; 15 | } 16 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/typeid.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | use type_safe_id::{TypeSafeId, DynamicType}; 5 | 6 | fuzz_target!(|id: TypeSafeId| { 7 | let id2 = id.to_string().parse().unwrap(); 8 | assert_eq!(id, id2); 9 | }); 10 | -------------------------------------------------------------------------------- /src/arbitrary.rs: -------------------------------------------------------------------------------- 1 | use arbitrary::{Arbitrary, Result, Unstructured}; 2 | use arrayvec::ArrayString; 3 | 4 | use crate::{DynamicType, StaticType, TypeSafeId}; 5 | 6 | #[cfg_attr(docsrs, doc(cfg(feature = "arbitrary")))] 7 | impl<'a, T: StaticType> Arbitrary<'a> for TypeSafeId { 8 | fn arbitrary(u: &mut Unstructured<'a>) -> Result { 9 | let millis = u.arbitrary::()? & 0xFFFF_FFFF_FFFF; // 48 bits 10 | let data: [u8; 10] = u.arbitrary()?; 11 | 12 | Ok(Self::from_type_and_uuid( 13 | T::default(), 14 | uuid::Builder::from_unix_timestamp_millis(millis, &data).into_uuid(), 15 | )) 16 | } 17 | } 18 | 19 | #[cfg_attr(docsrs, doc(cfg(feature = "arbitrary")))] 20 | impl<'a> Arbitrary<'a> for DynamicType { 21 | fn arbitrary(u: &mut Unstructured<'a>) -> Result { 22 | let size = u.arbitrary_len::()?; 23 | let mut str = ArrayString::<63>::new(); 24 | while !str.is_full() && str.len() < size { 25 | match u.peek_bytes(1) { 26 | Some([b @ b'a'..=b'z']) => { 27 | str.push(*b as char); 28 | u.bytes(1)?; 29 | } 30 | _ => break, 31 | } 32 | } 33 | Ok(DynamicType(str)) 34 | } 35 | } 36 | 37 | #[cfg_attr(docsrs, doc(cfg(feature = "arbitrary")))] 38 | impl<'a> Arbitrary<'a> for TypeSafeId { 39 | fn arbitrary(u: &mut Unstructured<'a>) -> Result { 40 | let tag: DynamicType = u.arbitrary()?; 41 | let millis = u.arbitrary::()? & 0xFFFF_FFFF_FFFF; // 48 bits 42 | let data: [u8; 10] = u.arbitrary()?; 43 | 44 | Ok(Self::from_type_and_uuid( 45 | tag, 46 | uuid::Builder::from_unix_timestamp_millis(millis, &data).into_uuid(), 47 | )) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A type-safe, K-sortable, globally unique identifier 2 | //! 3 | //! ``` 4 | //! use type_safe_id::{StaticType, TypeSafeId}; 5 | //! 6 | //! #[derive(Default)] 7 | //! struct User; 8 | //! 9 | //! impl StaticType for User { 10 | //! // must be lowercase ascii [a-z] only 11 | //! const TYPE: &'static str = "user"; 12 | //! } 13 | //! 14 | //! // type alias for your custom typed id 15 | //! type UserId = TypeSafeId; 16 | //! 17 | //! let user_id1 = UserId::new(); 18 | //! # std::thread::sleep(std::time::Duration::from_millis(10)); 19 | //! let user_id2 = UserId::new(); 20 | //! 21 | //! let uid1 = user_id1.to_string(); 22 | //! let uid2 = user_id2.to_string(); 23 | //! dbg!(&uid1, &uid2); 24 | //! assert!(uid2 > uid1, "type safe IDs are ordered"); 25 | //! 26 | //! let user_id3: UserId = uid1.parse().expect("invalid user id"); 27 | //! let user_id4: UserId = uid2.parse().expect("invalid user id"); 28 | //! 29 | //! assert_eq!(user_id1.uuid(), user_id3.uuid(), "round trip works"); 30 | //! assert_eq!(user_id2.uuid(), user_id4.uuid(), "round trip works"); 31 | //! ``` 32 | #![cfg_attr(docsrs, feature(doc_cfg))] 33 | #![forbid(unsafe_code)] 34 | 35 | #[cfg(feature = "arbitrary")] 36 | mod arbitrary; 37 | 38 | #[cfg(feature = "serde")] 39 | mod serde; 40 | 41 | use std::hash::Hash; 42 | use std::{borrow::Cow, fmt, str::FromStr}; 43 | 44 | use arrayvec::ArrayString; 45 | use uuid::{NoContext, Uuid}; 46 | 47 | #[non_exhaustive] 48 | #[derive(Debug, thiserror::Error)] 49 | pub enum Error { 50 | /// The ID type was not valid 51 | #[error("id type is invalid")] 52 | InvalidType, 53 | /// The ID type did not match the expected type 54 | #[error("id type {actual:?} does not match expected {expected:?}")] 55 | IncorrectType { 56 | actual: String, 57 | expected: Cow<'static, str>, 58 | }, 59 | /// The ID suffix was not valid 60 | #[error("id suffix is invalid")] 61 | InvalidData, 62 | /// The string was not formed as a type-id 63 | #[error("string is not a type-id")] 64 | NotATypeId, 65 | } 66 | 67 | /// A static type prefix 68 | pub trait StaticType: Default { 69 | /// must be lowercase ascii [a-z_] only, under 64 characters. 70 | /// first character cannot be an underscore 71 | const TYPE: &'static str; 72 | 73 | #[doc(hidden)] 74 | const __TYPE_PREFIX_IS_VALID: bool = { 75 | assert!(Self::TYPE.len() < 64); 76 | let mut i = 0; 77 | while i < Self::TYPE.len() { 78 | let b = Self::TYPE.as_bytes()[i]; 79 | assert!( 80 | matches!(b, b'a'..=b'z' | b'_'), 81 | "type prefix must contain only lowercase ascii, or underscores" 82 | ); 83 | i += 1; 84 | } 85 | if !Self::TYPE.is_empty() { 86 | assert!( 87 | Self::TYPE.as_bytes()[0] != b'_', 88 | "type prefix must not start with an underscore" 89 | ); 90 | assert!( 91 | Self::TYPE.as_bytes()[i - 1] != b'_', 92 | "type prefix must not end with an underscore" 93 | ); 94 | } 95 | true 96 | }; 97 | } 98 | 99 | /// Represents a type that can serialize to and be parsed from a tag 100 | pub trait Type: Sized { 101 | /// Try convert the prefix into the well known type. 102 | /// If the prefix is incorrect, return the expected prefix. 103 | fn try_from_type_prefix(tag: &str) -> Result>; 104 | 105 | /// Get the prefix from this type 106 | fn to_type_prefix(&self) -> &str; 107 | } 108 | 109 | impl Type for T { 110 | fn try_from_type_prefix(tag: &str) -> Result> { 111 | assert!(Self::__TYPE_PREFIX_IS_VALID); 112 | if tag != Self::TYPE { 113 | Err(Self::TYPE.into()) 114 | } else { 115 | Ok(T::default()) 116 | } 117 | } 118 | 119 | fn to_type_prefix(&self) -> &str { 120 | assert!(Self::__TYPE_PREFIX_IS_VALID); 121 | Self::TYPE 122 | } 123 | } 124 | 125 | /// A dynamic type prefix 126 | /// 127 | /// ``` 128 | /// use type_safe_id::{DynamicType, TypeSafeId}; 129 | /// 130 | /// let id: TypeSafeId = "prefix_01h2xcejqtf2nbrexx3vqjhp41".parse().unwrap(); 131 | /// 132 | /// assert_eq!(id.type_prefix(), "prefix"); 133 | /// assert_eq!(id.uuid(), uuid::uuid!("0188bac7-4afa-78aa-bc3b-bd1eef28d881")); 134 | /// ``` 135 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 136 | pub struct DynamicType(ArrayString<63>); 137 | 138 | impl DynamicType { 139 | /// Create a new type prefix from a dynamic str 140 | /// 141 | /// ``` 142 | /// use type_safe_id::{DynamicType, TypeSafeId}; 143 | /// 144 | /// let dynamic_type = DynamicType::new("prefix").unwrap(); 145 | /// 146 | /// let data = uuid::uuid!("0188bac7-4afa-78aa-bc3b-bd1eef28d881"); 147 | /// let id = TypeSafeId::from_type_and_uuid(dynamic_type, data); 148 | /// 149 | /// assert_eq!(id.to_string(), "prefix_01h2xcejqtf2nbrexx3vqjhp41"); 150 | /// ``` 151 | pub fn new(s: &str) -> Result { 152 | let tag: ArrayString<63> = s.try_into().map_err(|_| Error::InvalidType)?; 153 | 154 | if tag.bytes().any(|b| !matches!(b, b'a'..=b'z' | b'_')) { 155 | return Err(Error::InvalidType); 156 | } 157 | if !tag.is_empty() && (tag.as_bytes()[0] == b'_' || tag.as_bytes()[tag.len() - 1] == b'_') { 158 | return Err(Error::InvalidType); 159 | } 160 | Ok(Self(tag)) 161 | } 162 | } 163 | 164 | impl Type for DynamicType { 165 | fn try_from_type_prefix(tag: &str) -> Result> { 166 | let tag: ArrayString<63> = tag.try_into().map_err(|_| tag[..63].to_owned())?; 167 | if tag.bytes().any(|b| !matches!(b, b'a'..=b'z' | b'_')) { 168 | return Err(tag.to_lowercase().into()); 169 | } 170 | if !tag.is_empty() && (tag.as_bytes()[0] == b'_' || tag.as_bytes()[tag.len() - 1] == b'_') { 171 | return Err(tag.to_lowercase().into()); 172 | } 173 | Ok(Self(tag)) 174 | } 175 | 176 | fn to_type_prefix(&self) -> &str { 177 | &self.0 178 | } 179 | } 180 | 181 | /// A typed UUID. 182 | /// 183 | /// ``` 184 | /// use type_safe_id::{StaticType, TypeSafeId}; 185 | /// 186 | /// #[derive(Default)] 187 | /// struct User; 188 | /// 189 | /// impl StaticType for User { 190 | /// // must be lowercase ascii [a-z] only 191 | /// const TYPE: &'static str = "user"; 192 | /// } 193 | /// 194 | /// // type alias for your custom typed id 195 | /// type UserId = TypeSafeId; 196 | /// 197 | /// let user_id1 = UserId::new(); 198 | /// # std::thread::sleep(std::time::Duration::from_millis(10)); 199 | /// let user_id2 = UserId::new(); 200 | /// 201 | /// let uid1 = user_id1.to_string(); 202 | /// let uid2 = user_id2.to_string(); 203 | /// dbg!(&uid1, &uid2); 204 | /// assert!(uid2 > uid1, "type safe IDs are ordered"); 205 | /// 206 | /// let user_id3: UserId = uid1.parse().expect("invalid user id"); 207 | /// let user_id4: UserId = uid2.parse().expect("invalid user id"); 208 | /// 209 | /// assert_eq!(user_id1.uuid(), user_id3.uuid(), "round trip works"); 210 | /// assert_eq!(user_id2.uuid(), user_id4.uuid(), "round trip works"); 211 | /// ``` 212 | #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 213 | pub struct TypeSafeId { 214 | tag: T, 215 | data: Uuid, 216 | } 217 | 218 | impl fmt::Debug for TypeSafeId { 219 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 220 | f.debug_struct("TypeSafeId") 221 | .field("tag", &self.tag.to_type_prefix()) 222 | .field("data", &self.data) 223 | .finish() 224 | } 225 | } 226 | 227 | impl Hash for TypeSafeId { 228 | fn hash(&self, state: &mut H) { 229 | self.tag.to_type_prefix().hash(state); 230 | self.data.hash(state); 231 | } 232 | } 233 | 234 | struct Uuid128(u128); 235 | 236 | impl From for Uuid128 { 237 | fn from(value: Uuid) -> Self { 238 | Self(u128::from_be_bytes(value.into_bytes())) 239 | } 240 | } 241 | impl From for Uuid { 242 | fn from(value: Uuid128) -> Self { 243 | Uuid::from_bytes(value.0.to_be_bytes()) 244 | } 245 | } 246 | 247 | impl TypeSafeId { 248 | /// Create a new type-id 249 | #[allow(clippy::new_without_default)] 250 | pub fn new() -> Self { 251 | Self::new_with_ts_rng( 252 | T::default(), 253 | uuid::Timestamp::now(NoContext), 254 | &mut rand::thread_rng(), 255 | ) 256 | } 257 | 258 | /// Create a new type-id from the given uuid data 259 | pub fn from_uuid(data: Uuid) -> Self { 260 | Self::from_type_and_uuid(T::default(), data) 261 | } 262 | 263 | /// The length of a type-id string with the given (static) type 264 | pub const fn static_len() -> usize { 265 | T::TYPE.len() + // Prefix length 266 | 1 + // `_` length 267 | ENCODED_UUID_LEN 268 | } 269 | } 270 | 271 | impl From for TypeSafeId { 272 | fn from(data: Uuid) -> Self { 273 | Self::from_uuid(data) 274 | } 275 | } 276 | 277 | impl TypeSafeId { 278 | /// Create a new type-id with the given type prefix 279 | pub fn new_with_type(type_prefix: T) -> Self { 280 | Self::new_with_ts_rng( 281 | type_prefix, 282 | uuid::Timestamp::now(NoContext), 283 | &mut rand::thread_rng(), 284 | ) 285 | } 286 | 287 | fn new_with_ts_rng(type_prefix: T, ts: uuid::Timestamp, rng: &mut impl rand::Rng) -> Self { 288 | let (secs, nanos) = ts.to_unix(); 289 | let millis = (secs * 1000).saturating_add(nanos as u64 / 1_000_000); 290 | Self::from_type_and_uuid( 291 | type_prefix, 292 | uuid::Builder::from_unix_timestamp_millis(millis, &rng.gen()).into_uuid(), 293 | ) 294 | } 295 | 296 | /// Create a new type-id with the given type prefix and uuid data 297 | pub fn from_type_and_uuid(type_prefix: T, data: Uuid) -> Self { 298 | Self { 299 | tag: type_prefix, 300 | data, 301 | } 302 | } 303 | 304 | pub fn type_prefix(&self) -> &str { 305 | self.tag.to_type_prefix() 306 | } 307 | 308 | /// The length of a type-id string with the given type 309 | /// 310 | /// If your prefix is static, you can also use [`TypeSafeId::static_len`]. 311 | #[allow(clippy::len_without_is_empty)] 312 | pub fn len(&self) -> usize { 313 | self.type_prefix().len() + 314 | 1 + // `_` length 315 | ENCODED_UUID_LEN 316 | } 317 | 318 | pub fn uuid(&self) -> Uuid { 319 | self.data 320 | } 321 | } 322 | 323 | impl FromStr for TypeSafeId { 324 | type Err = Error; 325 | 326 | fn from_str(id: &str) -> Result { 327 | let (tag, id) = match id.rsplit_once('_') { 328 | Some(("", _)) => return Err(Error::InvalidType), 329 | Some((tag, id)) => (tag, id), 330 | None => ("", id), 331 | }; 332 | 333 | let tag = T::try_from_type_prefix(tag).map_err(|expected| Error::IncorrectType { 334 | actual: tag.into(), 335 | expected, 336 | })?; 337 | 338 | Ok(Self { 339 | tag, 340 | data: parse_base32_uuid7(id)?.into(), 341 | }) 342 | } 343 | } 344 | 345 | /// Encoded UUID length (see ) 346 | const ENCODED_UUID_LEN: usize = 26; 347 | 348 | fn parse_base32_uuid7(id: &str) -> Result { 349 | let mut id: [u8; ENCODED_UUID_LEN] = 350 | id.as_bytes().try_into().map_err(|_| Error::InvalidData)?; 351 | let mut max = 0; 352 | for b in &mut id { 353 | *b = CROCKFORD_INV[*b as usize]; 354 | max |= *b; 355 | } 356 | if max > 32 || id[0] > 7 { 357 | return Err(Error::InvalidData); 358 | } 359 | 360 | let mut out = 0u128; 361 | for b in id { 362 | out <<= 5; 363 | out |= b as u128; 364 | } 365 | 366 | Ok(Uuid128(out)) 367 | } 368 | 369 | impl fmt::Display for TypeSafeId { 370 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 371 | f.write_str(&to_array_string(self.type_prefix(), self.data.into())) 372 | } 373 | } 374 | 375 | fn to_array_string(prefix: &str, data: Uuid128) -> ArrayString<90> { 376 | let mut out = ArrayString::new(); 377 | 378 | if !prefix.is_empty() { 379 | out.push_str(prefix); 380 | out.push_str("_"); 381 | } 382 | 383 | let mut buf = [0; ENCODED_UUID_LEN]; 384 | let mut data = data.0; 385 | for b in buf.iter_mut().rev() { 386 | *b = CROCKFORD[((data as u8) & 0x1f) as usize]; 387 | debug_assert!(b.is_ascii()); 388 | data >>= 5; 389 | } 390 | 391 | let s = std::str::from_utf8(&buf).expect("only ascii bytes should be in the buffer"); 392 | 393 | out.push_str(s); 394 | out 395 | } 396 | 397 | const CROCKFORD: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; 398 | const CROCKFORD_INV: &[u8; 256] = &{ 399 | let mut output = [255; 256]; 400 | 401 | let mut i = 0; 402 | while i < 32 { 403 | output[CROCKFORD[i as usize] as usize] = i; 404 | i += 1; 405 | } 406 | 407 | output 408 | }; 409 | -------------------------------------------------------------------------------- /src/serde.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::{DynamicType, StaticType, Type, TypeSafeId}; 6 | 7 | #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] 8 | impl Serialize for TypeSafeId { 9 | fn serialize(&self, serializer: S) -> Result 10 | where 11 | S: serde::Serializer, 12 | { 13 | serializer.serialize_str(&super::to_array_string( 14 | self.type_prefix(), 15 | self.data.into(), 16 | )) 17 | } 18 | } 19 | 20 | #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] 21 | impl<'de, T: StaticType> Deserialize<'de> for TypeSafeId { 22 | fn deserialize(deserializer: D) -> Result 23 | where 24 | D: serde::Deserializer<'de>, 25 | { 26 | struct FromStrVisitor(std::marker::PhantomData); 27 | impl<'de, T: StaticType> serde::de::Visitor<'de> for FromStrVisitor { 28 | type Value = TypeSafeId; 29 | 30 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 31 | write!( 32 | formatter, 33 | "a string containing a type-id with {:?} type prefix", 34 | T::TYPE 35 | ) 36 | } 37 | fn visit_str(self, v: &str) -> Result 38 | where 39 | E: serde::de::Error, 40 | { 41 | v.parse().map_err(E::custom) 42 | } 43 | } 44 | deserializer.deserialize_str(FromStrVisitor(std::marker::PhantomData)) 45 | } 46 | } 47 | 48 | #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] 49 | impl<'de> Deserialize<'de> for TypeSafeId { 50 | fn deserialize(deserializer: D) -> Result 51 | where 52 | D: serde::Deserializer<'de>, 53 | { 54 | struct FromStrVisitor; 55 | impl<'de> serde::de::Visitor<'de> for FromStrVisitor { 56 | type Value = TypeSafeId; 57 | 58 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 59 | write!(formatter, "a string containing a type-id") 60 | } 61 | fn visit_str(self, v: &str) -> Result 62 | where 63 | E: serde::de::Error, 64 | { 65 | v.parse().map_err(E::custom) 66 | } 67 | } 68 | deserializer.deserialize_str(FromStrVisitor) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/spec.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use libtest_mimic::{Arguments, Trial}; 4 | use serde::Deserialize; 5 | use type_safe_id::{DynamicType, TypeSafeId}; 6 | use uuid::Uuid; 7 | 8 | #[derive(Deserialize)] 9 | struct Valid { 10 | name: String, 11 | typeid: String, 12 | prefix: String, 13 | uuid: Uuid, 14 | } 15 | 16 | #[derive(Deserialize)] 17 | struct Invalid { 18 | name: String, 19 | typeid: String, 20 | description: String, 21 | } 22 | 23 | type Id = TypeSafeId; 24 | 25 | fn main() { 26 | let mut tests = vec![]; 27 | 28 | let valid: Vec = serde_yaml::from_str(include_str!("spec/valid.yml")).unwrap(); 29 | let invalid: Vec = serde_yaml::from_str(include_str!("spec/invalid.yml")).unwrap(); 30 | 31 | for test in valid { 32 | tests.push(Trial::test(format!("valid::{}", test.name), move || { 33 | let id = match Id::from_str(&test.typeid) { 34 | Ok(id) => id, 35 | Err(e) => return Err(e.to_string().into()), 36 | }; 37 | if id.uuid() != test.uuid { 38 | return Err(format!("expected {:?}, got {:?}", test.uuid, id.uuid()).into()); 39 | } 40 | if id.type_prefix() != test.prefix { 41 | return Err( 42 | format!("expected {:?}, got {:?}", test.prefix, id.type_prefix()).into(), 43 | ); 44 | } 45 | 46 | if id.to_string() == test.typeid { 47 | Ok(()) 48 | } else { 49 | Err(format!("expected {:?}, got {:?}", test.typeid, id.to_string()).into()) 50 | } 51 | })) 52 | } 53 | 54 | for test in invalid { 55 | tests.push(Trial::test( 56 | format!("invalid::{}", test.name), 57 | move || match Id::from_str(&test.typeid) { 58 | Ok(_) => Err(test.description.into()), 59 | Err(_) => Ok(()), 60 | }, 61 | )) 62 | } 63 | 64 | let args = Arguments::from_args(); 65 | libtest_mimic::run(&args, tests).exit_if_failed(); 66 | } 67 | -------------------------------------------------------------------------------- /tests/spec/README.md: -------------------------------------------------------------------------------- 1 | # TypeID Specification (Version 0.3.0) 2 | 3 | ## Overview 4 | 5 | TypeIDs are a type-safe extension of UUIDv7, they encode UUIDs in base32 and add a type prefix. 6 | 7 | Here's an example of a TypeID of type `user`: 8 | 9 | ``` 10 | user_2x4y6z8a0b1c2d3e4f5g6h7j8k 11 | └──┘ └────────────────────────┘ 12 | type uuid suffix (base32) 13 | ``` 14 | 15 | This document formalizes the specification for TypeIDs. 16 | 17 | ## Specification 18 | 19 | A typeid consists of three parts: 20 | 21 | 1. A **type prefix**: a string denoting the type of the ID. The prefix should be 22 | at most 63 characters in all lowercase snake_case ASCII `[a-z_]`. 23 | 1. A **separator**: an underscore `_` character. The separator is omitted if the prefix is empty. 24 | 1. A **UUID suffix**: a 128-bit UUIDv7 encoded as a 26-character string in base32. 25 | 26 | ### Type Prefix 27 | 28 | A type prefix is a string denoting the type of the ID. 29 | The prefix must: 30 | 31 | - Contain at most 63 characters. 32 | - May be empty. 33 | - If not empty: 34 | - Must contain only lowercase alphabetic ASCII characters `[a-z]`, or an underscore `_`. 35 | - Must start and end with an alphabetic character `[a-z]`. Underscores are not allowed at the beginning or end of the string. 36 | 37 | Valid prefixes match the following 38 | regex: `^([a-z]([a-z_]{0,61}[a-z])?)?$`. 39 | 40 | The empty string is a valid prefix, it's there for use cases in which 41 | applications need to encode a typeid but elide the type information. In general though, 42 | applications SHOULD use a prefix that is at least 3 characters long. 43 | 44 | ### Separator 45 | 46 | The separator is a single underscore character `_`. If the prefix is empty, the separator 47 | is omitted. 48 | 49 | ### UUID Suffix 50 | 51 | The UUID suffix encodes exactly 128-bits of data in 26 characters. It uses the base32 52 | encoding described below. 53 | 54 | #### Base32 Encoding 55 | 56 | Bytes from the UUID are encoded from left to right. Two zeroed bits are pre-pended 57 | to the 128-bits of the UUID, resulting in 130-bits of data. The 130-bits are then 58 | split into 5-bit chunks, and each chunk is encoded as a single character in the 59 | base32 alphabet, resulting in a total of 26 characters. 60 | 61 | In practice this is most often done by using bit-shifting and a lookup table. See 62 | the [reference implementation encoding](https://github.com/jetify-com/typeid-go/blob/main/base32/base32.go) 63 | for an example. 64 | 65 | Note that this is different from the standard base32 encoding which encodes in 66 | groups of 5 bytes (40 bits) and appends any padding at the end of the data. 67 | 68 | The encoding uses the following alphabet `0123456789abcdefghjkmnpqrstvwxyz` as 69 | specified by the following table: 70 | 71 | | Value | Symbol | Value | Symbol | Value | Symbol | Value | Symbol | 72 | | ----- | ------ | ----- | ------ | ----- | ------ | ----- | ------ | 73 | | 0 | 0 | 8 | 8 | 16 | g | 24 | r | 74 | | 1 | 1 | 9 | 9 | 17 | h | 25 | s | 75 | | 2 | 2 | 10 | a | 18 | j | 26 | t | 76 | | 3 | 3 | 11 | b | 19 | k | 27 | v | 77 | | 4 | 4 | 12 | c | 20 | m | 28 | w | 78 | | 5 | 5 | 13 | d | 21 | n | 29 | x | 79 | | 6 | 6 | 14 | e | 22 | p | 30 | y | 80 | | 7 | 7 | 15 | f | 23 | q | 31 | z | 81 | 82 | This is the same alphabet used by [Crockford's base32 encoding](https://www.crockford.com/base32.html), 83 | but in our case the alphabet encoding is strict: always in lowercase, no hyphens allowed, 84 | and we never decode multiple ambiguous characters to the same value. 85 | 86 | Technically speaking, 26 characters in base32 can encode 130 bits of data, but UUIDs 87 | are 128 bits. To prevent overflow errors, the maximum possible suffix for a typeid 88 | is `7zzzzzzzzzzzzzzzzzzzzzzzzz`. Implementations should reject any suffix greater than 89 | that value, by checking that the first character is a `7` or less. 90 | 91 | #### Compatibility with UUID 92 | 93 | When genarating a new TypeID, the generated UUID suffix MUST decode to a valid UUIDv7. 94 | 95 | Implementations SHOULD allow encoding/decoding of other UUID variants when the 96 | bits are provided by end users. This makes it possible for applications to encode 97 | other UUID variants like UUIDv1 or UUIDv4 at their discretion. 98 | 99 | ## Versioning 100 | 101 | This spec uses semantic versioning: `MAJOR.MINOR.PATCH`. The version is incremented 102 | when the spec changes in a way that is not backwards compatible. 103 | 104 | Libraries that implement this spec should also use semantic versioning. 105 | 106 | ## Validating Implementations 107 | 108 | To assist library authors in validating their implementations, we provide: 109 | 110 | - A [reference implementation in Go](https://github.com/jetify-com/typeid-go) 111 | with extensive testing. 112 | - A [valid.yml](valid.yml) file containing a list of valid typeids along 113 | with their corresponding decoded UUIDs. For convienience, we also provide 114 | a [valid.json](valid.json) file containing the same data in JSON format. 115 | - An [invalid.yml](invalid.yml) file containing a list of strings that are 116 | invalid typeids and should fail to parse/decode. For convienience, we also 117 | provide a [invalid.json](invalid.json) file containing the same data in 118 | JSON format. 119 | -------------------------------------------------------------------------------- /tests/spec/invalid.yml: -------------------------------------------------------------------------------- 1 | # This file contains test data that should be treated as *invalid* TypeIDs by 2 | # conforming implementations. 3 | # 4 | # Each example contains an invalid TypeID string. Implementations are expected 5 | # to throw an error when attempting to parse/validate these strings. 6 | # 7 | # Last updated: 2024-05-18 (for version 0.3.0 of the spec) 8 | 9 | - name: prefix-uppercase 10 | typeid: "PREFIX_00000000000000000000000000" 11 | description: "The prefix should be lowercase with no uppercase letters" 12 | 13 | - name: prefix-numeric 14 | typeid: "12345_00000000000000000000000000" 15 | description: "The prefix can't have numbers, it needs to be alphabetic" 16 | 17 | - name: prefix-period 18 | typeid: "pre.fix_00000000000000000000000000" 19 | description: "The prefix can't have symbols, it needs to be alphabetic" 20 | 21 | # Test removed in v0.3.0 – we now allow underscores in the prefix 22 | # - name: prefix-underscore 23 | # typeid: "pre_fix_00000000000000000000000000" 24 | # description: "The prefix can't have symbols, it needs to be alphabetic" 25 | 26 | - name: prefix-non-ascii 27 | typeid: "préfix_00000000000000000000000000" 28 | description: "The prefix can only have ascii letters" 29 | 30 | - name: prefix-spaces 31 | typeid: " prefix_00000000000000000000000000" 32 | description: "The prefix can't have any spaces" 33 | 34 | - name: prefix-64-chars 35 | # 123456789 123456789 123456789 123456789 123456789 123456789 1234 36 | typeid: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl_00000000000000000000000000" 37 | description: "The prefix can't be 64 characters, it needs to be 63 characters or less" 38 | 39 | - name: separator-empty-prefix 40 | typeid: "_00000000000000000000000000" 41 | description: "If the prefix is empty, the separator should not be there" 42 | 43 | - name: separator-empty 44 | typeid: "_" 45 | description: "A separator by itself should not be treated as the empty string" 46 | 47 | - name: suffix-short 48 | typeid: "prefix_1234567890123456789012345" 49 | description: "The suffix can't be 25 characters, it needs to be exactly 26 characters" 50 | 51 | - name: suffix-long 52 | typeid: "prefix_123456789012345678901234567" 53 | description: "The suffix can't be 27 characters, it needs to be exactly 26 characters" 54 | 55 | - name: suffix-spaces 56 | # This example has the right length, so that the failure is caused by the space 57 | # and not the suffix length 58 | typeid: "prefix_1234567890123456789012345 " 59 | description: "The suffix can't have any spaces" 60 | 61 | - name: suffix-uppercase 62 | # This example is picked because it would be valid in lowercase 63 | typeid: "prefix_0123456789ABCDEFGHJKMNPQRS" 64 | description: "The suffix should be lowercase with no uppercase letters" 65 | 66 | - name: suffix-hyphens 67 | # This example has the right length, so that the failure is caused by the hyphens 68 | # and not the suffix length 69 | typeid: "prefix_123456789-123456789-123456" 70 | description: "The suffix can't have any hyphens" 71 | 72 | - name: suffix-wrong-alphabet 73 | typeid: "prefix_ooooooiiiiiiuuuuuuulllllll" 74 | description: "The suffix should only have letters from the spec's alphabet" 75 | 76 | - name: suffix-ambiguous-crockford 77 | # This example would be valid if we were using the crockford disambiguation rules 78 | typeid: "prefix_i23456789ol23456789oi23456" 79 | description: "The suffix should not have any ambiguous characters from the crockford encoding" 80 | 81 | - name: suffix-hyphens-crockford 82 | # This example would be valid if we were using the crockford hyphenation rules 83 | typeid: "prefix_123456789-0123456789-0123456" 84 | description: "The suffix can't ignore hyphens as in the crockford encoding" 85 | 86 | - name: suffix-overflow 87 | # This is the first suffix that overflows into 129 bits 88 | typeid: "prefix_8zzzzzzzzzzzzzzzzzzzzzzzzz" 89 | description: "The suffix should encode at most 128-bits" 90 | 91 | # Tests below were added in v0.3.0 when we started allowing '_' within the 92 | # type prefix. 93 | - name: prefix-underscore-start 94 | typeid: "_prefix_00000000000000000000000000" 95 | description: "The prefix can't start with an underscore" 96 | 97 | - name: prefix-underscore-end 98 | typeid: "prefix__00000000000000000000000000" 99 | description: "The prefix can't end with an underscore" 100 | -------------------------------------------------------------------------------- /tests/spec/valid.yml: -------------------------------------------------------------------------------- 1 | # This file contains test data that should parse as valid TypeIDs by conforming 2 | # implementations. 3 | # 4 | # Each example contains: 5 | # - The TypeID in its canonical string representation. 6 | # - The prefix 7 | # - The decoded UUID as a hex string 8 | # 9 | # Implementations should verify that they can encode/decode the data 10 | # in both directions: 11 | # 1. If the TypeID is decoded, it should result in the given prefix and UUID. 12 | # 2. If the UUID is encoded as a TypeID with the given prefix, it should 13 | # result in the given TypeID. 14 | # 15 | # In addition to using these examples, it's recommended that implementations 16 | # generate a thousands of random ids during testing, and verify that after 17 | # decoding and re-encoding the id, the result is the same as the original. 18 | # 19 | # In other words, the following property should always hold: 20 | # random_typeid == encode(decode(random_typeid)) 21 | # 22 | # Finally, while implementations should be able to decode the values below, 23 | # note that not all of them are UUIDv7s. When *generating* new random typeids, 24 | # implementations should always use UUIDv7s. 25 | # 26 | # Last updated: 2024-04-10 (for version 0.3.0 of the spec) 27 | 28 | - name: nil 29 | typeid: "00000000000000000000000000" 30 | prefix: "" 31 | uuid: "00000000-0000-0000-0000-000000000000" 32 | 33 | - name: one 34 | typeid: "00000000000000000000000001" 35 | prefix: "" 36 | uuid: "00000000-0000-0000-0000-000000000001" 37 | 38 | - name: ten 39 | typeid: "0000000000000000000000000a" 40 | prefix: "" 41 | uuid: "00000000-0000-0000-0000-00000000000a" 42 | 43 | - name: sixteen 44 | typeid: "0000000000000000000000000g" 45 | prefix: "" 46 | uuid: "00000000-0000-0000-0000-000000000010" 47 | 48 | - name: thirty-two 49 | typeid: "00000000000000000000000010" 50 | prefix: "" 51 | uuid: "00000000-0000-0000-0000-000000000020" 52 | 53 | - name: max-valid 54 | typeid: "7zzzzzzzzzzzzzzzzzzzzzzzzz" 55 | prefix: "" 56 | uuid: "ffffffff-ffff-ffff-ffff-ffffffffffff" 57 | 58 | - name: valid-alphabet 59 | typeid: "prefix_0123456789abcdefghjkmnpqrs" 60 | prefix: "prefix" 61 | uuid: "0110c853-1d09-52d8-d73e-1194e95b5f19" 62 | 63 | - name: valid-uuidv7 64 | typeid: "prefix_01h455vb4pex5vsknk084sn02q" 65 | prefix: "prefix" 66 | uuid: "01890a5d-ac96-774b-bcce-b302099a8057" 67 | 68 | # Tests below were added in v0.3.0 when we started allowing '_' within the 69 | # type prefix. 70 | - name: prefix-underscore 71 | typeid: "pre_fix_00000000000000000000000000" 72 | prefix: "pre_fix" 73 | uuid: "00000000-0000-0000-0000-000000000000" 74 | --------------------------------------------------------------------------------