diff --git a/.gitignore b/.gitignore index 3a83c2f..1b1cb36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,8 @@ -# See https://www.dartlang.org/guides/libraries/private-files - -# Files and directories created by pub +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` .dart_tool/ -.packages -build/ -# If you're building an application, you may want to check-in your pubspec.lock -pubspec.lock - -# Directory created by dartdoc -# If you don't generate documentation locally you can remove this line. -doc/api/ -# dotenv environment variables file +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock .env* - -# Avoid committing generated Javascript files: -*.dart.js -*.info.json # Produced by the --dump-info flag. -*.js # When generated by dart2js. Don't specify *.js if your - # project includes source files written in JavaScript. -*.js_ -*.js.deps -*.js.map - -.flutter-plugins -.flutter-plugins-dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..415ee68 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2024] [SticksDev] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e7e7dd --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +This package allows you to generate unique and random IDs for your Flutter/dart applications using the ID generator provided +by [team hydra](https://teamhydra.dev/). + +## Features + +This generator provides the following ID types: + +- UUIDv4 generation +- NanoID generation (with and without custom alphabet) +- Word generation +- 2FA generation +- Keypair generation +- Snowflake generation (discord-like snowflake) +- License key generation + +## Getting started + +You will need a valid username and token to use this package. If you do not have one and would like to use this package, please contact us on our [discord server](https://discord.gg/zira) in the `#other-support` channel and someone will assist you. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/example/idgen_dart_example.dart b/example/idgen_dart_example.dart new file mode 100644 index 0000000..89b80f0 --- /dev/null +++ b/example/idgen_dart_example.dart @@ -0,0 +1,11 @@ +// Import the package +import 'package:teamhydra_idgen/teamhydra_idgen.dart'; + +void main() async { + IDGen idgen = IDGen(username: 'your_username', token: 'your_token'); + + // Generate a new ID, in this example we are generating a UUID V4 + IDGenResponse uuid = + await idgen.generateUUIDV4(); // all generations are async + print('Generated UUID: ${uuid.id}'); // print the generated ID +} diff --git a/lib/src/idgen_dart_base.dart b/lib/src/idgen_dart_base.dart new file mode 100644 index 0000000..6203553 --- /dev/null +++ b/lib/src/idgen_dart_base.dart @@ -0,0 +1,40 @@ +/// Response class for IDs generated by IDGen. +class IDGenResponse { + final String id; + + IDGenResponse({required this.id}); + + factory IDGenResponse.fromJson(Map json) { + return IDGenResponse( + id: json['id'], + ); + } +} + +/// Response class for keypairs generated by IDGen. +class IDKeypairResponse implements IDGenResponse { + @override + final String id; + final String privateID; + + IDKeypairResponse({required this.id, required this.privateID}); + + factory IDKeypairResponse.fromJson(Map json) { + return IDKeypairResponse( + id: json['id'], + privateID: json['privateID'], + ); + } +} + +/// Custom exception for IDGen, thrown when an error occurs when generating an ID. +class IDGenException implements Exception { + final String message; + + IDGenException(this.message); + + @override + String toString() { + return message; + } +} diff --git a/lib/src/idgen_http.dart b/lib/src/idgen_http.dart new file mode 100644 index 0000000..e89baae --- /dev/null +++ b/lib/src/idgen_http.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:teamhydra_idgen/src/idgen_dart_base.dart'; + +class IDGenHTTPWorker { + final Dio _dio = Dio(); + final String _baseURL = 'https://id.hydra.workers.dev'; + + final String username; + final String token; + + IDGenHTTPWorker({required this.username, required this.token}); + + Future generate( + String type, Map? data) async { + try { + final response = await _dio.post(_baseURL, + data: { + 'username': username, + 'token': token, + 'type': type, + ...?data, + }, + options: Options(contentType: Headers.jsonContentType)); + + return IDGenResponse.fromJson(response.data); + } on DioException catch (e) { + Map? errorData; + + // Do we have a response? + if (e.response != null) { + // Try decoding the response + try { + errorData = jsonDecode(e.response!.data); + } catch (_) { + // Do nothing + } + } + + if (errorData != null && errorData['error'] != null) { + throw IDGenException( + 'Server rejected ID generation: ${errorData['error']}'); + } else if (errorData != null) { + throw IDGenException('Server rejected ID generation: $errorData'); + } else { + throw IDGenException( + 'An error occurred during generation ($type): ${e.message}'); + } + } catch (e) { + throw IDGenException( + 'An unknown error occurred during generation ($type): $e'); + } + } + + Future generateKeypair() async { + try { + final response = await _dio.post(_baseURL, + data: { + 'username': username, + 'token': token, + 'type': 'keypair', + }, + options: Options(contentType: Headers.jsonContentType)); + + return IDKeypairResponse.fromJson(response.data); + } on DioException catch (e) { + final errorData = e.response?.data; + if (errorData != null && errorData['error'] != null) { + throw IDGenException( + 'Server rejected ID generation: ${errorData['error']}'); + } else if (errorData != null) { + throw IDGenException('Server rejected ID generation: $errorData'); + } else { + throw IDGenException( + 'An error occurred during generation (keypair): ${e.message}'); + } + } catch (e) { + throw IDGenException( + 'An unknown error occurred during generation (keypair): $e'); + } + } +} diff --git a/lib/src/idgen_main.dart b/lib/src/idgen_main.dart new file mode 100644 index 0000000..6e2ebef --- /dev/null +++ b/lib/src/idgen_main.dart @@ -0,0 +1,145 @@ +import 'package:teamhydra_idgen/src/idgen_http.dart'; +import 'package:teamhydra_idgen/teamhydra_idgen.dart'; + +/// The main class to use when generating an ID +/// +/// All returned IDs are in the form of a [IDGenResponse] object which contains the ID. +/// If an error occurs, an [IDGenException] is thrown. +class IDGen { + final String username; + final String token; + final IDGenHTTPWorker _worker; + + /// Create a new IDGen class to generate IDs + /// + /// [username] and [token] are required to authenticate with the IDGen API. + IDGen({required this.username, required this.token}) + : _worker = IDGenHTTPWorker(username: username, token: token); + + /// Generate a new UUID V4 + /// + /// Returns a [IDGenResponse] object containing the generated ID. + /// Throws an [IDGenException] if an error occurs. + Future generateUUIDV4() async { + return await _worker.generate('uuid', null); + } + + /// Generate a new nanoID + /// + /// Returns a [IDGenResponse] object containing the generated ID. + /// Optionally, you can pass a [size] parameter to specify the length of the nanoID, default is 10. + /// You can also pass a [alphabet] parameter to specify the characters used in the nanoID, default is provided by the API. + /// + /// Throws an [IDGenException] if an error occurs. + /// + /// Throws an [ArgumentError] if the size is not between 1 and 256 or the alphabet is empty. + Future generateNanoID({int? size, String? alphabet}) async { + // Ensure length is between 1 and 256 (if specified) + if (size != null && (size < 1 || size > 256)) { + throw ArgumentError( + 'Cannot generate a nanoID with a length of $size, must be between 1 and 256'); + } + + // Ensure alphabet is not empty (if specified) + if (alphabet != null && alphabet.isEmpty) { + throw ArgumentError('Cannot generate a nanoID with an empty alphabet'); + } + + // Ensure alphabet is at least 3 characters long and not longer than 256 + if (alphabet != null && (alphabet.length < 3 || alphabet.length > 256)) { + throw ArgumentError( + 'Cannot generate a nanoID with an alphabet of length ${alphabet.length}, must be between 3 and 256'); + } + + return await _worker.generate('nanoid', { + if (size != null) 'length': size else 'length': 10, + if (alphabet != null) 'alphabet': alphabet, + }); + } + + /// Generate a 2FA code pair + /// + /// Returns a [IDGenResponse] object containing the generated ID. + /// + /// Optionally, you can pass a [length] parameter to specify the length of the 2FA code, default is 6. + /// + /// Throws an [IDGenException] if an error occurs. + /// + /// Throws an [ArgumentError] if the length is not between 1 and 256. + Future generate2FACode({int? length}) async { + // Ensure length is between 1 and 256 (if specified) + if (length != null && (length < 1 || length > 256)) { + throw ArgumentError( + 'Cannot generate a 2FA code with a length of $length, must be between 1 and 256'); + } + + return await _worker.generate('2fa', { + if (length != null) 'length': length, + }); + } + + /// Generate a license key + /// + /// Returns a [IDGenResponse] object containing the generated ID. + /// Keys are generated with a 25 character length, resulting in a 5-5-5-5-5 format. + /// + /// Throws an [IDGenException] if an error occurs. + Future generateLicenseKey() async { + return await _worker.generate('license', null); + } + + /// Generate word based string + /// + /// Returns a [IDGenResponse] object containing the generated ID. + /// Optionally, you can pass a [length] parameter to specify the length of the word based string, default is 5. + /// You can also pass a [separator] parameter to specify the separator used in the word based string, options are 'slug', 'title' and 'formal'. Default is 'slug'. + /// + /// Slug: lowercase words separated by hyphens + /// + /// Title: Title case words separated by spaces + /// + /// Formal: Title case words with no spaces or separators + /// + /// Throws an [IDGenException] if an error occurs. + /// + /// Throws an [ArgumentError] if the length is not between 1 and 16 or the separator is invalid. + Future generateWordBasedString( + {int? length, String? separator}) async { + // Ensure length is between 1 and 256 (if specified) + if (length != null && (length < 1 || length > 16)) { + throw ArgumentError( + 'Cannot generate a word based string with a length of $length, must be between 1 and 16'); + } + + // Ensure separator is valid (if specified) + if (separator != null && + separator != 'slug' && + separator != 'title' && + separator != 'formal') { + throw ArgumentError( + 'Cannot generate a word based string with an invalid separator'); + } + + return await _worker.generate('word', { + if (length != null) 'length': length else 'length': 5, + if (separator != null) 'style': separator else 'style': 'slug', + }); + } + + /// Generate a snowflake ID + /// + /// Returns a [IDKeypairResponse] object containing + /// + /// Throws an [IDGenException] if an error occurs. + Future generateSnowflakeID() async { + return await _worker.generate('snowflake', null); + } + + /// Generate a keypair + /// + /// Returns a [IDKeypairResponse] object containing the generated ID and secret key. + /// Throws an [IDGenException] if an error occurs. + Future generateKeypair() async { + return await _worker.generateKeypair(); + } +} diff --git a/lib/teamhydra_idgen.dart b/lib/teamhydra_idgen.dart new file mode 100644 index 0000000..b24d89d --- /dev/null +++ b/lib/teamhydra_idgen.dart @@ -0,0 +1,5 @@ +/// Team Hydra ID Generator Library - Generate unique IDs for your projects, using the IDGen API. +library; + +export 'src/idgen_dart_base.dart'; +export 'src/idgen_main.dart'; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..3f1d88e --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,17 @@ +name: teamhydra_idgen +description: A library for generating unique IDs using Team Hydra's ID generation service. +version: 1.0.0 +repository: https://teamhydra.io/Sticks/idgen-dart + +environment: + sdk: ^3.5.4 + +# Add regular dependencies here. +dependencies: + dio: ^5.7.0 + # path: ^1.8.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.24.0 + dotenv: ^4.2.0 diff --git a/test/idgen_dart_test.dart b/test/idgen_dart_test.dart new file mode 100644 index 0000000..87b8edf --- /dev/null +++ b/test/idgen_dart_test.dart @@ -0,0 +1,318 @@ +import 'dart:io'; +import 'package:teamhydra_idgen/teamhydra_idgen.dart'; +import 'package:test/test.dart'; + +Map loadRequiredVarsFromEnv() { + // Load the .env file from the current directory + final currentDir = Directory.current; + final envFile = File('${currentDir.path}/.env'); + + if (!envFile.existsSync()) { + throw Exception( + 'Could not locate the .env file in the current directory (tried path: ${envFile.path})'); + } + + final lines = envFile + .readAsLinesSync() + .where((line) => line.isNotEmpty && !line.startsWith('#')) + .toList(); // Filter out empty lines and comments + + String username = ''; + String token = ''; + + // Get the username and token (IDGEN_USERNAME and IDGEN_TOKEN) + for (final line in lines) { + if (line.startsWith('IDGEN_USERNAME=')) { + username = line.split('=')[1].trim(); + } else if (line.startsWith('IDGEN_TOKEN=')) { + token = line.split('=')[1].trim(); + } + } + + // Remove " from the strings + username = username.replaceAll('"', ''); + token = token.replaceAll('"', ''); + + if (username.isEmpty || token.isEmpty) { + throw Exception( + 'IDGEN_USERNAME or IDGEN_TOKEN is missing from the .env file'); + } + + return {'username': username, 'token': token}; +} + +void main() { + // Load the .env file and get the username and token + final env = loadRequiredVarsFromEnv(); + + if (env['username'] == null || + env['token'] == null || + env['username']!.isEmpty || + env['token']!.isEmpty) { + print('Please provide a valid username and token in the .env file'); + exit(1); + } + + final username = env['username']!; + final token = env['token']!; + + print( + "[IDGen] Loaded credentials for username: $username and token (last 4): ${token.substring(token.length - 4)} to run tests"); + + group('IDGen', () { + final idGen = IDGen(username: username, token: token); + + // Ensure it errors when invalid user/token combo is used + test('Invalid user/token combo', () async { + final idGen = IDGen(username: 'invalid', token: 'invalid'); + + try { + await idGen.generateUUIDV4(); + fail('Should have thrown an exception'); + } on IDGenException catch (e) { + expect(e.message, + 'Server rejected ID generation: Invalid username or token'); + } catch (e) { + fail('Should have thrown an IDGenException'); + } + }); + + // Ensure it generates a UUID V4 + test('Generate UUID V4', () async { + final response = await idGen.generateUUIDV4(); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 36); + }); + + // Ensure it generates a nanoID + test('Generate nanoID', () async { + final response = await idGen.generateNanoID(); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 10); + }); + + // Ensure it generates a nanoID with a custom size + test('Generate nanoID with custom size', () async { + final response = await idGen.generateNanoID(size: 20); + print('[IDGen] Generated nanoID with size 20: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 20); + }); + + // Ensure it generates a nanoID with a custom alphabet + test('Generate nanoID with custom alphabet', () async { + final response = + await idGen.generateNanoID(alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + + print('[IDGen] Generated nanoID with custom alphabet: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 10); + }); + + // All together now + test('Generate nanoID with custom size and alphabet', () async { + final response = await idGen.generateNanoID( + size: 20, alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); + print( + '[IDGen] Generated nanoID with size 20 and custom alphabet: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 20); + }); + + // Ensure it throws an error when generating a nanoID with an empty alphabet + test('Generate nanoID with empty alphabet', () async { + try { + await idGen.generateNanoID(alphabet: ''); + fail('Should have thrown an exception'); + } on ArgumentError catch (e) { + expect(e.message, 'Cannot generate a nanoID with an empty alphabet'); + } catch (e) { + fail('Should have thrown an ArgumentError'); + } + }); + + // Ensure it throws an error when generating a nanoID with an invalid alphabet length + test('Generate nanoID with invalid alphabet length', () async { + try { + await idGen.generateNanoID(alphabet: 'AB'); + fail('Should have thrown an exception'); + } on ArgumentError catch (e) { + expect(e.message, + 'Cannot generate a nanoID with an alphabet of length 2, must be between 3 and 256'); + } catch (e) { + fail('Should have thrown an ArgumentError'); + } + }); + + // Ensure it throws an error when generating a nanoID with an invalid size + test('Generate nanoID with invalid size', () async { + try { + await idGen.generateNanoID(size: 0); + fail('Should have thrown an exception'); + } on ArgumentError catch (e) { + expect(e.message, + 'Cannot generate a nanoID with a length of 0, must be between 1 and 256'); + } catch (e) { + fail('Should have thrown an ArgumentError'); + } + }); + + // Ensure it generates a 2FA code pair + test('Generate 2FA code pair', () async { + final response = await idGen.generate2FACode(); + print('[IDGen] Generated 2FA code: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 6); + }); + + // Ensure it generates a 2FA code pair with a custom length + test('Generate 2FA code pair with custom length', () async { + final response = await idGen.generate2FACode(length: 10); + print('[IDGen] Generated 2FA code with length 10: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 10); + }); + + // Ensure it throws an error when generating a 2FA code with an invalid length + test('Generate 2FA code pair with invalid length', () async { + try { + await idGen.generate2FACode(length: 0); + fail('Should have thrown an exception'); + } on ArgumentError catch (e) { + expect(e.message, + 'Cannot generate a 2FA code with a length of 0, must be between 1 and 256'); + } catch (e) { + fail('Should have thrown an ArgumentError'); + } + }); + + // Ensure it generates a license key + test('Generate license key', () async { + final response = await idGen.generateLicenseKey(); + print('[IDGen] Generated license key: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 29); + + // Make sure it follows the 5-5-5-5-5 format + final parts = response.id.split('-'); + expect(parts.length, 5); + }); + + // Ensure it generates a word based string + test('Generate word based string', () async { + final response = await idGen.generateWordBasedString(); + print('[IDGen] Generated word based string: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + }); + + // Ensure it generates a word based string with a custom length + test('Generate word based string with custom length', () async { + final response = await idGen.generateWordBasedString(length: 10); + print( + '[IDGen] Generated word based string with length 10: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + + // Should contain -'s as the default separator + expect(response.id.contains('-'), isTrue); + }); + + // Ensure it can generate all separators for word based strings + test('Generate word based string with custom separator', () async { + final responseSlug = + await idGen.generateWordBasedString(separator: 'slug'); + print( + '[IDGen] Generated word based string with slug separator: ${responseSlug.id}'); + + final responseTitle = + await idGen.generateWordBasedString(separator: 'title'); + print( + '[IDGen] Generated word based string with title separator: ${responseTitle.id}'); + + final responseFormal = + await idGen.generateWordBasedString(separator: 'formal'); + print( + '[IDGen] Generated word based string with formal separator: ${responseFormal.id}'); + + expect(responseSlug.id, isNotNull); + expect(responseSlug.id, isNotEmpty); + + expect(responseTitle.id, isNotNull); + expect(responseTitle.id, isNotEmpty); + + expect(responseFormal.id, isNotNull); + expect(responseFormal.id, isNotEmpty); + }); + }); + + // Ensure it errors when a invalid separator is used for word based strings + test('Generate word based string with invalid separator', () async { + final idGen = IDGen(username: username, token: token); + try { + await idGen.generateWordBasedString(separator: 'invalid'); + fail('Should have thrown an exception'); + } on ArgumentError catch (e) { + expect(e.message, + 'Cannot generate a word based string with an invalid separator'); + } catch (e) { + fail('Should have thrown an ArgumentError'); + } + }); + + // Ensure it errors when a invalid length is used for word based strings + test('Generate word based string with invalid length', () async { + final idGen = IDGen(username: username, token: token); + try { + await idGen.generateWordBasedString(length: 0); + fail('Should have thrown an exception'); + } on ArgumentError catch (e) { + expect(e.message, + 'Cannot generate a word based string with a length of 0, must be between 1 and 16'); + } catch (e) { + fail('Should have thrown an ArgumentError'); + } + }); + + // Ensure it generates a snowflake ID + test('Generate snowflake ID', () async { + final idGen = IDGen(username: username, token: token); + final response = await idGen.generateSnowflakeID(); + print('[IDGen] Generated snowflake ID: ${response.id}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.id.length, 20); + }); + + // Ensure it generates a keypair + test('Generate keypair', () async { + final idGen = IDGen(username: username, token: token); + final response = await idGen.generateKeypair(); + print( + '[IDGen] Generated keypair: ${response.id} and secret: ${response.privateID}'); + + expect(response.id, isNotNull); + expect(response.id, isNotEmpty); + expect(response.privateID, isNotNull); + expect(response.privateID, isNotEmpty); + }); +}