diff --git a/changelogs/fragments/2244-hashids-filters.yml b/changelogs/fragments/2244-hashids-filters.yml new file mode 100644 index 0000000000..568119e890 --- /dev/null +++ b/changelogs/fragments/2244-hashids-filters.yml @@ -0,0 +1,6 @@ +--- +add plugin.filter: + - name: hashids_encode + description: Encodes YouTube-like hashes from a sequence of integers + - name: hashids_decode + description: Decodes a sequence of numbers from a YouTube-like hash diff --git a/plugins/filter/hashids.py b/plugins/filter/hashids.py new file mode 100644 index 0000000000..c4735afeae --- /dev/null +++ b/plugins/filter/hashids.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible.errors import ( + AnsibleError, + AnsibleFilterError, + AnsibleFilterTypeError, +) + +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.common.collections import is_sequence + +try: + from hashids import Hashids + HAS_HASHIDS = True +except ImportError: + HAS_HASHIDS = False + + +def initialize_hashids(**kwargs): + if not HAS_HASHIDS: + raise AnsibleError("The hashids library must be installed in order to use this plugin") + + params = dict((k, v) for k, v in kwargs.items() if v) + + try: + return Hashids(**params) + except TypeError as e: + raise AnsibleFilterError( + "The provided parameters %s are invalid: %s" % ( + ', '.join(["%s=%s" % (k, v) for k, v in params.items()]), + to_native(e) + ) + ) + + +def hashids_encode(nums, salt=None, alphabet=None, min_length=None): + """Generates a YouTube-like hash from a sequence of ints + + :nums: Sequence of one or more ints to hash + :salt: String to use as salt when hashing + :alphabet: String of 16 or more unique characters to produce a hash + :min_length: Minimum length of hash produced + """ + + hashids = initialize_hashids( + salt=salt, + alphabet=alphabet, + min_length=min_length + ) + + # Handles the case where a single int is not encapsulated in a list or tuple. + # User convenience seems preferable to strict typing in this case + # Also avoids obfuscated error messages related to single invalid inputs + if not is_sequence(nums): + nums = [nums] + + try: + hashid = hashids.encode(*nums) + except TypeError as e: + raise AnsibleFilterTypeError( + "Data to encode must by a tuple or list of ints: %s" % to_native(e) + ) + + return hashid + + +def hashids_decode(hashid, salt=None, alphabet=None, min_length=None): + """Decodes a YouTube-like hash to a sequence of ints + + :hashid: Hash string to decode + :salt: String to use as salt when hashing + :alphabet: String of 16 or more unique characters to produce a hash + :min_length: Minimum length of hash produced + """ + + hashids = initialize_hashids( + salt=salt, + alphabet=alphabet, + min_length=min_length + ) + nums = hashids.decode(hashid) + return list(nums) + + +class FilterModule(object): + + def filters(self): + return { + 'hashids_encode': hashids_encode, + 'hashids_decode': hashids_decode, + } diff --git a/tests/integration/targets/filter_hashids/aliases b/tests/integration/targets/filter_hashids/aliases new file mode 100644 index 0000000000..f04737b845 --- /dev/null +++ b/tests/integration/targets/filter_hashids/aliases @@ -0,0 +1,2 @@ +shippable/posix/group2 +skip/python2.6 # filters are controller only, and we no longer support Python 2.6 on the controller diff --git a/tests/integration/targets/filter_hashids/runme.sh b/tests/integration/targets/filter_hashids/runme.sh new file mode 100755 index 0000000000..313ea4bb83 --- /dev/null +++ b/tests/integration/targets/filter_hashids/runme.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -eux + +export ANSIBLE_TEST_PREFER_VENV=1 # see https://github.com/ansible/ansible/pull/73000#issuecomment-757012395; can be removed once Ansible 2.9 and ansible-base 2.10 support has been dropped +source virtualenv.sh + +# Requirements have to be installed prior to running ansible-playbook +# because plugins and requirements are loaded before the task runs + +pip install hashids + +ANSIBLE_ROLES_PATH=../ ansible-playbook runme.yml "$@" diff --git a/tests/integration/targets/filter_hashids/runme.yml b/tests/integration/targets/filter_hashids/runme.yml new file mode 100644 index 0000000000..b2a39e27a6 --- /dev/null +++ b/tests/integration/targets/filter_hashids/runme.yml @@ -0,0 +1,3 @@ +- hosts: localhost + roles: + - { role: filter_hashids } diff --git a/tests/integration/targets/filter_hashids/tasks/main.yml b/tests/integration/targets/filter_hashids/tasks/main.yml new file mode 100644 index 0000000000..95bcc91346 --- /dev/null +++ b/tests/integration/targets/filter_hashids/tasks/main.yml @@ -0,0 +1,58 @@ +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- name: Test valid hashable inputs + assert: + that: + - "single_int | community.general.hashids_encode | community.general.hashids_decode == [single_int]" + - "int_list | community.general.hashids_encode | community.general.hashids_decode | list == int_list" + - "(1,2,3) | community.general.hashids_encode | community.general.hashids_decode == [1,2,3]" + +- name: Test valid parameters + assert: + that: + - "single_int | community.general.hashids_encode(salt='test') | community.general.hashids_decode(salt='test') == [single_int]" + - "single_int | community.general.hashids_encode(alphabet='1234567890abcdef') | community.general.hashids_decode(alphabet='1234567890abcdef') == [single_int]" + - "single_int | community.general.hashids_encode(min_length=20) | community.general.hashids_decode(min_length=20) == [single_int]" + - "single_int | community.general.hashids_encode(min_length=20) | length == 20" + +- name: Test valid unhashable inputs + assert: + that: + - "single_float | community.general.hashids_encode | community.general.hashids_decode == []" + - "arbitrary_string | community.general.hashids_encode | community.general.hashids_decode == []" + +- name: Register result of invalid salt + debug: + var: "invalid_input | community.general.hashids_encode(salt=10)" + register: invalid_salt_message + ignore_errors: true + +- name: Test invalid salt fails + assert: + that: + - invalid_salt_message is failed + +- name: Register result of invalid alphabet + debug: + var: "invalid_input | community.general.hashids_encode(alphabet='abc')" + register: invalid_alphabet_message + ignore_errors: true + +- name: Test invalid alphabet fails + assert: + that: + - invalid_alphabet_message is failed + +- name: Register result of invalid min_length + debug: + var: "invalid_input | community.general.hashids_encode(min_length='foo')" + register: invalid_min_length_message + ignore_errors: true + +- name: Test invalid min_length fails + assert: + that: + - invalid_min_length_message is failed diff --git a/tests/integration/targets/filter_hashids/vars/main.yml b/tests/integration/targets/filter_hashids/vars/main.yml new file mode 100644 index 0000000000..3f2b0c5f98 --- /dev/null +++ b/tests/integration/targets/filter_hashids/vars/main.yml @@ -0,0 +1,4 @@ +single_int: 1 +int_list: [1, 2, 3] +single_float: [2.718] +arbitrary_string: "will not hash"