Repository: getmoto/moto
Pull request #8216 (2024-10-09 05:09:34)

Support custom ids
# Motivation

This pr aims to introduce a mechanism by which we can define custom ids in order to be able to create a resource with deterministic ids.

A `ResourceIdentifier` base class will allow us to quickly create objects to manage ids. Each resource can implement their own `generate` method giving us much flexibility when it comes to the type of id created.

The `MotoIdManager` implements an interface over the registered custom ids. It also contains a method `add_id_source` that will allow us the possibility to further expand the behaviour of the id manager.

# Changes

- Implementation of the id manager
- Integration of the `ResourceIdentifier` in `apigateway` and `secretsmanager` as to provide examples of how it can be used.
- Added tests for implemented services.

# Not in scope

Many services can benefit from this upgrade, but only 2 are implemented here to provide a sample of how to use. Follow PRs will spread the functionality to more services. This should help the review process over smaller PRs!

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8216?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
Attention: Patch coverage is `99.11504%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 94.54%. Comparing base [(`43410b9`)](https://app.codecov.io/gh/getmoto/moto/commit/43410b987e1a5a172a7d429d68f5c94105154476?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`dd111ab`)](https://app.codecov.io/gh/getmoto/moto/commit/dd111ab5d91d5421677ce8ea8d1e61a29d6022bf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).
> Report is 6 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/getmoto/moto/pull/8216?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Patch % | Lines |
|---|---|---|
| [moto/utilities/id\_generator.py](https://app.codecov.io/gh/getmoto/moto/pull/8216?src=pr&el=tree&filepath=moto%2Futilities%2Fid_generator.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by91dGlsaXRpZXMvaWRfZ2VuZXJhdG9yLnB5) | 98.14% | [1 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/8216?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           master    #8216    +/-   ##
========================================
  Coverage   94.53%   94.54%            
========================================
  Files        1155     1158     +3     
  Lines       99707   100025   +318     
========================================
+ Hits        94261    94566   +305     
- Misses       5446     5459    +13     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8216/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8216/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.85% <79.64%> (+0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8216/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.51% <99.11%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8216?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2b8c40350..324638669 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [ 3.8, 3.9, "3.10", "3.11", "3.12", "3.13.0-rc.1" ]
+        python-version: [ 3.8, 3.9, "3.10", "3.11", "3.12", "3.13" ]
 
     steps:
       - uses: actions/checkout@v4
@@ -30,11 +30,6 @@ jobs:
         if: ${{ steps.pip-cache.outputs.cache-hit != 'true' }}
         run: |
           python -m pip install --upgrade pip
-      - name: Install XML dependencies
-        if: ${{ matrix.python-version == '3.13.0-rc.1' }}
-        run: |
-          echo "The libxml dependency needs these system packages to compile in Python 3.13"
-          sudo apt install -y libxml2-dev libxslt-dev
       - name: Install project dependencies
         if: ${{ steps.pip-cache.outputs.cache-hit != 'true' }}
         run: |
@@ -47,7 +42,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13.0-rc.1"]
+        python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
     steps:
     - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
@@ -68,7 +63,7 @@ jobs:
     - name: Update pip
       run: |
         python -m pip install --upgrade pip
-    # Still need to properly install the dependencies - it will only skip the download part
+    # Still need to properly install the dependencies - caching will only skip the download part
     - name: Install project dependencies
       run: |
         pip install -r requirements-dev.txt
@@ -81,6 +76,10 @@ jobs:
     needs: lint
     uses: ./.github/workflows/tests_sdk_java.yml
 
+  jstest:
+    needs: lint
+    uses: ./.github/workflows/tests_sdk_js.yml
+
   dotnettest:
     needs: lint
     uses: ./.github/workflows/tests_sdk_dotnet.yml
@@ -109,113 +108,3 @@ jobs:
     needs: [lint]
     if: "!contains(github.event.pull_request.labels.*.name, 'java')"
     uses: ./.github/workflows/tests_proxymode.yml
-
-  release:
-    name: Release
-    runs-on: ubuntu-latest
-    permissions:
-      id-token: write
-      packages: write
-      pull-requests: write
-    needs: [dotnettest, javatest, test, testserver ]
-    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'getmoto/moto' }}
-    steps:
-      - uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-      - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v5
-        with:
-          python-version: ${{ matrix.python-version }}
-      - name: Update & Build
-        run: |
-          pip install build
-          python update_version_from_git.py
-          python -m build
-      - name: Publish to PyPI
-        uses: pypa/gh-action-pypi-publish@release/v1
-        with:
-          verbose: true
-          print-hash: true
-      - name: Build Docker release
-        run: |
-          docker build -t motoserver/moto . --tag moto:latest
-      # Required to get the correct Digest
-      # See https://github.com/docker/build-push-action/issues/461
-      - name: Set up QEMU
-        uses: docker/setup-qemu-action@v3
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
-      - name: Login to DockerHub
-        uses: docker/login-action@v3
-        with:
-          username: ${{ secrets.DOCKERHUB_USERNAME }}
-          password: ${{ secrets.DOCKERHUB_TOKEN }}
-      - name: Login to GHCR
-        uses: docker/login-action@v3
-        with:
-          registry: ghcr.io
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-      - name: Push to DockerHub and GHCR
-        uses: docker/build-push-action@v6
-        with:
-          push: true
-          platforms: linux/amd64,linux/arm64
-          tags: |
-            motoserver/moto:latest
-            ghcr.io/getmoto/motoserver:latest
-      - name: Get version number
-        run: |
-          version=$(grep -oP '(?<=__version__ = ")[0-9.a-z]+(?=")' moto/__init__.py)
-          echo "moto_version=$version" >> $GITHUB_ENV
-      - uses: octokit/graphql-action@v2.x
-        name: Get PR info
-        id: get_pr
-        with:
-          query: |
-            query get_pr($owner:String!,$repo:String!,$commit:GitObjectID) {
-              repository(owner:$owner,name:$repo) {
-                object(oid:$commit) {
-                  ... on Commit {
-                    associatedPullRequests(last: 1){
-                      edges {
-                        node {
-                          baseRepository {
-                            nameWithOwner
-                          }
-                          merged
-                          number
-                        }
-                      }
-                    }
-                  }
-                }
-              }
-            }
-          owner: ${{ github.event.repository.owner.name }}
-          repo: ${{ github.event.repository.name }}
-          commit: "${{ github.sha }}"
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-      - name: Get PR number
-        run: |
-          nr="${{ fromJSON(steps.get_pr.outputs.data).repository.object.associatedPullRequests.edges[0].node.number }}"
-          repo="${{ fromJSON(steps.get_pr.outputs.data).repository.object.associatedPullRequests.edges[0].node.baseRepository.nameWithOwner }}"
-          if [ -z "$nr" ]
-          then
-            echo "PR nr not found in $msg"
-            echo "pr_found=false" >> $GITHUB_ENV
-          else
-            echo "PR NR: $nr"
-            echo "pr_nr=$nr" >> $GITHUB_ENV
-            echo "pr_repo=$repo" >> $GITHUB_ENV
-            echo "pr_found=true" >> $GITHUB_ENV
-          fi
-      - name: Leave PR comment with Moto version
-        uses: peter-evans/create-or-update-comment@v4
-        if: env.pr_found == 'true' && env.pr_repo == 'getmoto/moto'
-        with:
-          issue-number: ${{ env.pr_nr }}
-          body: |
-            This is now part of moto >= ${{ env.moto_version }}
diff --git a/.github/workflows/release_dev.yml b/.github/workflows/release_dev.yml
new file mode 100644
index 000000000..cadd644fe
--- /dev/null
+++ b/.github/workflows/release_dev.yml
@@ -0,0 +1,57 @@
+name: Release Dev
+
+on: [workflow_dispatch]
+
+jobs:
+
+  release:
+    name: Dev Release
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      packages: write
+      pull-requests: write
+    if: ${{ github.repository == 'getmoto/moto' }}
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Update & Build
+        run: |
+          pip install build
+          python update_version_from_git.py
+          python -m build
+      - name: Publish to PyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+      - name: Build Docker release
+        run: |
+          docker build -t motoserver/moto . --tag moto:latest
+      # Required to get the correct Digest
+      # See https://github.com/docker/build-push-action/issues/461
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+      - name: Login to DockerHub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Login to GHCR
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - name: Push to DockerHub and GHCR
+        uses: docker/build-push-action@v6
+        with:
+          push: true
+          platforms: linux/amd64,linux/arm64
+          tags: |
+            motoserver/moto:latest
+            ghcr.io/getmoto/motoserver:latest
diff --git a/.gitignore b/.gitignore
index 9587a7d81..467eb180b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,5 @@ other_langs/terraform/*/.terraform*
 other_langs/terraform/*/terraform*
 other_langs/terraform/awslambda/lambda_function_payload.zip
 other_langs/tests_dotnet/ebs/obj/Debug/*
+other_langs/tests_js/node_modules
+other_langs/tests_js/package-lock.json
diff --git a/moto/apigateway/models.py b/moto/apigateway/models.py
index 0973dcbc3..fad7ca7e1 100644
--- a/moto/apigateway/models.py
+++ b/moto/apigateway/models.py
@@ -68,7 +68,19 @@ from .exceptions import (
     ValidationException,
     VpcLinkNotFound,
 )
-from .utils import create_id, to_path
+from .utils import (
+    ApigwApiKeyIdentifier,
+    ApigwAuthorizerIdentifier,
+    ApigwDeploymentIdentifier,
+    ApigwModelIdentifier,
+    ApigwRequestValidatorIdentifier,
+    ApigwResourceIdentifier,
+    ApigwRestApiIdentifier,
+    ApigwUsagePlanIdentifier,
+    ApigwVpcLinkIdentifier,
+    create_id,
+    to_path,
+)
 
 STAGE_URL = "https://{api_id}.execute-api.{region_name}.amazonaws.com/{stage_name}"
 PATCH_OPERATIONS = ["add", "remove", "replace", "move", "copy", "test"]
@@ -789,6 +801,7 @@ class Stage(BaseModel):
 class ApiKey(BaseModel):
     def __init__(
         self,
+        api_key_id: str,
         name: Optional[str] = None,
         description: Optional[str] = None,
         enabled: bool = False,
@@ -798,7 +811,7 @@ class ApiKey(BaseModel):
         tags: Optional[List[Dict[str, str]]] = None,
         customerId: Optional[str] = None,
     ):
-        self.id = create_id()
+        self.id = api_key_id
         self.value = value or "".join(
             random.sample(string.ascii_letters + string.digits, 40)
         )
@@ -846,6 +859,7 @@ class ApiKey(BaseModel):
 class UsagePlan(BaseModel):
     def __init__(
         self,
+        usage_plan_id: str,
         name: Optional[str] = None,
         description: Optional[str] = None,
         apiStages: Any = None,
@@ -854,7 +868,7 @@ class UsagePlan(BaseModel):
         productCode: Optional[str] = None,
         tags: Optional[List[Dict[str, str]]] = None,
     ):
-        self.id = create_id()
+        self.id = usage_plan_id
         self.name = name
         self.description = description
         self.api_stages = apiStages or []
@@ -985,12 +999,13 @@ class UsagePlanKey(BaseModel):
 class VpcLink(BaseModel):
     def __init__(
         self,
+        vpc_link_id: str,
         name: str,
         description: str,
         target_arns: List[str],
         tags: List[Dict[str, str]],
     ):
-        self.id = create_id()
+        self.id = vpc_link_id
         self.name = name
         self.description = description
         self.target_arns = target_arns
@@ -1162,7 +1177,9 @@ class RestAPI(CloudFormationModel):
         )
 
     def add_child(self, path: str, parent_id: Optional[str] = None) -> Resource:
-        child_id = create_id()
+        child_id = ApigwResourceIdentifier(
+            self.account_id, self.region_name, parent_id or "", path
+        ).generate()
         child = Resource(
             resource_id=child_id,
             account_id=self.account_id,
@@ -1181,7 +1198,9 @@ class RestAPI(CloudFormationModel):
         schema: str,
         content_type: str,
     ) -> "Model":
-        model_id = create_id()
+        model_id = ApigwModelIdentifier(
+            self.account_id, self.region_name, name
+        ).generate()
         new_model = Model(
             model_id=model_id,
             name=name,
@@ -1293,7 +1312,11 @@ class RestAPI(CloudFormationModel):
     ) -> Deployment:
         if stage_variables is None:
             stage_variables = {}
-        deployment_id = create_id()
+        # Since there are no unique values to a deployment, we will use the stage name for the deployment.
+        # We are also passing a list of deployment ids to generate to prevent overwriting deployments.
+        deployment_id = ApigwDeploymentIdentifier(
+            self.account_id, self.region_name, stage_name=name
+        ).generate(list(self.deployments.keys()))
         deployment = Deployment(deployment_id, name, description)
         self.deployments[deployment_id] = deployment
         if name:
@@ -1332,7 +1355,9 @@ class RestAPI(CloudFormationModel):
         validateRequestBody: Optional[bool],
         validateRequestParameters: Any,
     ) -> RequestValidator:
-        validator_id = create_id()
+        validator_id = ApigwRequestValidatorIdentifier(
+            self.account_id, self.region_name, name
+        ).generate()
         request_validator = RequestValidator(
             _id=validator_id,
             name=name,
@@ -1631,7 +1656,9 @@ class APIGatewayBackend(BaseBackend):
         minimum_compression_size: Optional[int] = None,
         disable_execute_api_endpoint: Optional[bool] = None,
     ) -> RestAPI:
-        api_id = create_id()
+        api_id = ApigwRestApiIdentifier(
+            self.account_id, self.region_name, name
+        ).generate()
         rest_api = RestAPI(
             api_id,
             self.account_id,
@@ -1882,7 +1909,9 @@ class APIGatewayBackend(BaseBackend):
         self, restapi_id: str, name: str, authorizer_type: str, **kwargs: Any
     ) -> Authorizer:
         api = self.get_rest_api(restapi_id)
-        authorizer_id = create_id()
+        authorizer_id = ApigwAuthorizerIdentifier(
+            self.account_id, self.region_name, name
+        ).generate()
         return api.create_authorizer(
             authorizer_id,
             name,
@@ -2146,12 +2175,22 @@ class APIGatewayBackend(BaseBackend):
             for api_key in self.get_api_keys():
                 if api_key.value == payload["value"]:
                     raise ApiKeyAlreadyExists()
-        key = ApiKey(**payload)
+        api_key_id = ApigwApiKeyIdentifier(
+            self.account_id,
+            self.region_name,
+            # The value of an api key must be unique on aws
+            payload.get("value", ""),
+        ).generate()
+        key = ApiKey(api_key_id=api_key_id, **payload)
         self.keys[key.id] = key
         return key
 
     def get_api_keys(self, name: Optional[str] = None) -> List[ApiKey]:
-        return [key for key in self.keys.values() if not name or name == key.name]
+        return [
+            key
+            for key in self.keys.values()
+            if not name or (key.name and key.name.startswith(name))
+        ]
 
     def get_api_key(self, api_key_id: str) -> ApiKey:
         if api_key_id not in self.keys:
@@ -2166,7 +2205,10 @@ class APIGatewayBackend(BaseBackend):
         self.keys.pop(api_key_id)
 
     def create_usage_plan(self, payload: Any) -> UsagePlan:
-        plan = UsagePlan(**payload)
+        usage_plan_id = ApigwUsagePlanIdentifier(
+            self.account_id, self.region_name, payload["name"]
+        ).generate()
+        plan = UsagePlan(usage_plan_id=usage_plan_id, **payload)
         self.usage_plans[plan.id] = plan
         return plan
 
@@ -2230,7 +2272,11 @@ class APIGatewayBackend(BaseBackend):
 
         plan_keys = self.usage_plan_keys[usage_plan_id].values()
         if name:
-            return [key for key in plan_keys if not name or key.name == name]
+            return [
+                key
+                for key in plan_keys
+                if not name or (key.name and key.name.startswith(name))
+            ]
         return list(plan_keys)
 
     def get_usage_plan_key(self, usage_plan_id: str, key_id: str) -> UsagePlanKey:
@@ -2489,8 +2535,15 @@ class APIGatewayBackend(BaseBackend):
         target_arns: List[str],
         tags: List[Dict[str, str]],
     ) -> VpcLink:
+        vpc_link_id = ApigwVpcLinkIdentifier(
+            self.account_id, self.region_name, name
+        ).generate()
         vpc_link = VpcLink(
-            name, description=description, target_arns=target_arns, tags=tags
+            vpc_link_id,
+            name,
+            description=description,
+            target_arns=target_arns,
+            tags=tags,
         )
         self.vpc_links[vpc_link.id] = vpc_link
         return vpc_link
diff --git a/moto/apigateway/utils.py b/moto/apigateway/utils.py
index 971fd218c..3fcc25f28 100644
--- a/moto/apigateway/utils.py
+++ b/moto/apigateway/utils.py
@@ -1,10 +1,81 @@
 import json
 import string
-from typing import Any, Dict
+from typing import Any, Dict, List, Union
 
 import yaml
 
 from moto.moto_api._internal import mock_random as random
+from moto.utilities.id_generator import ResourceIdentifier, Tags, generate_str_id
+
+
+class ApigwIdentifier(ResourceIdentifier):
+    service = "apigateway"
+
+    def __init__(self, account_id: str, region: str, name: str):
+        super().__init__(account_id, region, name)
+
+    def generate(
+        self, existing_ids: Union[List[str], None] = None, tags: Tags = None
+    ) -> str:
+        return generate_str_id(
+            resource_identifier=self,
+            existing_ids=existing_ids,
+            tags=tags,
+            length=10,
+            include_digits=True,
+            lower_case=True,
+        )
+
+
+class ApigwApiKeyIdentifier(ApigwIdentifier):
+    resource = "api_key"
+
+    def __init__(self, account_id: str, region: str, value: str):
+        super().__init__(account_id, region, value)
+
+
+class ApigwAuthorizerIdentifier(ApigwIdentifier):
+    resource = "authorizer"
+
+
+class ApigwDeploymentIdentifier(ApigwIdentifier):
+    resource = "deployment"
+
+    def __init__(self, account_id: str, region: str, stage_name: str):
+        super().__init__(account_id, region, stage_name)
+
+
+class ApigwModelIdentifier(ApigwIdentifier):
+    resource = "model"
+
+
+class ApigwRequestValidatorIdentifier(ApigwIdentifier):
+    resource = "request_validator"
+
+
+class ApigwResourceIdentifier(ApigwIdentifier):
+    resource = "resource"
+
+    def __init__(
+        self, account_id: str, region: str, parent_id: str = "", path_name: str = "/"
+    ):
+        super().__init__(
+            account_id,
+            region,
+            ".".join((parent_id, path_name)),
+        )
+
+
+class ApigwRestApiIdentifier(ApigwIdentifier):
+    resource = "rest_api"
+
+
+class ApigwUsagePlanIdentifier(ApigwIdentifier):
+    resource = "usage_plan"
+
+
+class ApigwVpcLinkIdentifier(ApigwIdentifier):
+    resource = "vpc_link"
 
 
 def create_id() -> str:
diff --git a/moto/ec2/responses/instances.py b/moto/ec2/responses/instances.py
index 27e35b2b4..472ffd904 100644
--- a/moto/ec2/responses/instances.py
+++ b/moto/ec2/responses/instances.py
@@ -468,7 +468,7 @@ INSTANCE_TEMPLATE = """<item>
           {% if instance.key_name is not none %}
              <keyName>{{ instance.key_name }}</keyName>
           {% endif %}
-          <ebsOptimized>{{ instance.ebs_optimized }}</ebsOptimized>
+          <ebsOptimized>{{ instance.ebs_optimized | lower }}</ebsOptimized>
           <amiLaunchIndex>{{ instance.ami_launch_index }}</amiLaunchIndex>
           <instanceType>{{ instance.instance_type }}</instanceType>
           {% if instance.iam_instance_profile %}
@@ -537,7 +537,7 @@ INSTANCE_TEMPLATE = """<item>
                      <volumeId>{{ deviceobject.volume_id }}</volumeId>
                      <status>{{ deviceobject.status }}</status>
                      <attachTime>{{ deviceobject.attach_time }}</attachTime>
-                     <deleteOnTermination>{{ deviceobject.delete_on_termination }}</deleteOnTermination>
+                     <deleteOnTermination>{{ 'true' if deviceobject.delete_on_termination else 'false' }}</deleteOnTermination>
                      <size>{{deviceobject.size}}</size>
                 </ebs>
               </item>
diff --git a/moto/rds/models.py b/moto/rds/models.py
index 21c5f083a..2a768a91a 100644
--- a/moto/rds/models.py
+++ b/moto/rds/models.py
@@ -939,9 +939,9 @@ class DBInstance(CloudFormationModel, RDSBaseModel):
                 <VpcId>{{ database.db_subnet_group.vpc_id }}</VpcId>
               </DBSubnetGroup>
               {% endif %}
-              <PubliclyAccessible>{{ database.publicly_accessible }}</PubliclyAccessible>
-              <CopyTagsToSnapshot>{{ database.copy_tags_to_snapshot }}</CopyTagsToSnapshot>
-              <AutoMinorVersionUpgrade>{{ database.auto_minor_version_upgrade }}</AutoMinorVersionUpgrade>
+              <PubliclyAccessible>{{ 'true' if database.publicly_accessible else 'false' }}</PubliclyAccessible>
+              <CopyTagsToSnapshot>{{ 'true' if database.copy_tags_to_snapshot else 'false' }}</CopyTagsToSnapshot>
+              <AutoMinorVersionUpgrade>{{ 'true' if database.auto_minor_version_upgrade else 'false' }}</AutoMinorVersionUpgrade>
               <AllocatedStorage>{{ database.allocated_storage }}</AllocatedStorage>
               <StorageEncrypted>{{ 'true' if database.storage_encrypted else 'false' }}</StorageEncrypted>
               {% if database.kms_key_id %}
diff --git a/moto/resourcegroupstaggingapi/models.py b/moto/resourcegroupstaggingapi/models.py
index 8cafdb18b..b384042f6 100644
--- a/moto/resourcegroupstaggingapi/models.py
+++ b/moto/resourcegroupstaggingapi/models.py
@@ -25,6 +25,7 @@ from moto.sagemaker.models import SageMakerModelBackend, sagemaker_backends
 from moto.sns.models import SNSBackend, sns_backends
 from moto.sqs.models import SQSBackend, sqs_backends
 from moto.ssm.models import SimpleSystemManagerBackend, ssm_backends
+from moto.stepfunctions.models import StepFunctionBackend, stepfunctions_backends
 from moto.utilities.tagging_service import TaggingService
 from moto.utilities.utils import get_partition
 from moto.workspaces.models import WorkSpacesBackend, workspaces_backends
@@ -121,6 +122,10 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
     def sqs_backend(self) -> SQSBackend:
         return sqs_backends[self.account_id][self.region_name]
 
+    @property
+    def stepfunctions_backend(self) -> StepFunctionBackend:
+        return stepfunctions_backends[self.account_id][self.region_name]
+
     @property
     def backup_backend(self) -> BackupBackend:
         return backup_backends[self.account_id][self.region_name]
@@ -525,6 +530,13 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                     "ResourceARN": f"arn:{get_partition(self.region_name)}:ssm:{self.region_name}:{self.account_id}:document/{doc_name}",
                     "Tags": tags,
                 }
+        # Step Functions
+        if not resource_type_filters or "states:stateMachine" in resource_type_filters:
+            for state_machine in self.stepfunctions_backend.state_machines:
+                tags = format_tag_keys(state_machine.tags, ["key", "value"])
+                if not tags or not tag_filter(tags):
+                    continue
+                yield {"ResourceARN": state_machine.arn, "Tags": tags}
 
         # Workspaces
         if self.workspaces_backend and (
@@ -845,10 +857,9 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
         new_token = str(mock_random.uuid4())
         self._pages[new_token] = {"gen": generator, "misc": next_item}
 
-        # Token used up, might as well bin now, if you call it again your an idiot
+        # Token used up, might as well bin now, if you call it again you're an idiot
         if pagination_token:
             del self._pages[pagination_token]
-
         return new_token, result
 
     def get_tag_keys(
diff --git a/moto/secretsmanager/models.py b/moto/secretsmanager/models.py
index 5a3cf7f05..8d3d1d5a2 100644
--- a/moto/secretsmanager/models.py
+++ b/moto/secretsmanager/models.py
@@ -26,7 +26,11 @@ from .list_secrets.filters import (
     tag_key,
     tag_value,
 )
-from .utils import get_secret_name_from_partial_arn, random_password, secret_arn
+from .utils import (
+    SecretsManagerSecretIdentifier,
+    get_secret_name_from_partial_arn,
+    random_password,
+)
 
 MAX_RESULTS_DEFAULT = 100
 
@@ -94,7 +98,9 @@ class FakeSecret:
     ):
         self.secret_id = secret_id
         self.name = secret_id
-        self.arn = secret_arn(account_id, region_name, secret_id)
+        self.arn = SecretsManagerSecretIdentifier(
+            account_id, region_name, secret_id
+        ).generate()
         self.account_id = account_id
         self.region = region_name
         self.secret_string = secret_string
@@ -935,7 +941,9 @@ class SecretsManagerBackend(BaseBackend):
             if not force_delete_without_recovery:
                 raise SecretNotFoundException()
             else:
-                arn = secret_arn(self.account_id, self.region_name, secret_id=secret_id)
+                arn = SecretsManagerSecretIdentifier(
+                    self.account_id, self.region_name, secret_id=secret_id
+                ).generate()
                 name = secret_id
                 deletion_date = utcnow()
                 return arn, name, self._unix_time_secs(deletion_date)
@@ -948,7 +956,7 @@ class SecretsManagerBackend(BaseBackend):
                 msg = f"You can't delete secret {secret_id} that still has replica regions [{replica_regions}]"
                 raise InvalidParameterException(msg)
 
-            if secret.is_deleted():
+            if secret.is_deleted() and not force_delete_without_recovery:
                 raise InvalidRequestException(
                     "An error occurred (InvalidRequestException) when calling the DeleteSecret operation: You tried to \
                     perform the operation on a secret that's currently marked deleted."
diff --git a/moto/secretsmanager/utils.py b/moto/secretsmanager/utils.py
index e5435a6c9..e4b55622b 100644
--- a/moto/secretsmanager/utils.py
+++ b/moto/secretsmanager/utils.py
@@ -2,6 +2,12 @@ import re
 import string
 
 from moto.moto_api._internal import mock_random as random
+from moto.utilities.id_generator import (
+    ExistingIds,
+    ResourceIdentifier,
+    Tags,
+    generate_str_id,
+)
 from moto.utilities.utils import ARN_PARTITION_REGEX, get_partition
 
 
@@ -62,11 +68,6 @@ def random_password(
     return password
 
 
-def secret_arn(account_id: str, region: str, secret_id: str) -> str:
-    id_string = "".join(random.choice(string.ascii_letters) for _ in range(6))
-    return f"arn:{get_partition(region)}:secretsmanager:{region}:{account_id}:secret:{secret_id}-{id_string}"
-
-
 def get_secret_name_from_partial_arn(partial_arn: str) -> str:
     # We can retrieve a secret either using a full ARN, or using a partial ARN
     # name:        testsecret
@@ -99,3 +100,24 @@ def _add_password_require_each_included_type(
     password_with_required_char += required_characters
 
     return password_with_required_char
+
+
+class SecretsManagerSecretIdentifier(ResourceIdentifier):
+    service = "secretsmanager"
+    resource = "secret"
+
+    def __init__(self, account_id: str, region: str, secret_id: str):
+        super().__init__(account_id, region, name=secret_id)
+
+    def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str:
+        id_string = generate_str_id(
+            resource_identifier=self,
+            existing_ids=existing_ids,
+            tags=tags,
+            length=6,
+            include_digits=False,
+        )
+        return (
+            f"arn:{get_partition(self.region)}:secretsmanager:{self.region}:"
+            f"{self.account_id}:secret:{self.name}-{id_string}"
+        )
diff --git a/moto/utilities/id_generator.py b/moto/utilities/id_generator.py
new file mode 100644
index 000000000..c5f0a2c93
--- /dev/null
+++ b/moto/utilities/id_generator.py
@@ -0,0 +1,180 @@
+import abc
+import logging
+import threading
+from typing import Any, Callable, Dict, List, TypedDict, Union
+
+from moto.moto_api._internal import mock_random
+
+log = logging.getLogger(__name__)
+
+ExistingIds = Union[List[str], None]
+Tags = Union[Dict[str, str], None]
+
+# Custom resource tag to override the generated resource ID.
+TAG_KEY_CUSTOM_ID = "_custom_id_"
+
+
+class IdSourceContext(TypedDict, total=False):
+    resource_identifier: "ResourceIdentifier"
+    tags: Tags
+    existing_ids: ExistingIds
+
+
+class ResourceIdentifier(abc.ABC):
+    """
+    Base class for resource identifiers. When implementing a new resource, it is important to set
+    the service and resource as they will be used to create the unique identifier for that resource.
+
+    It is recommended to implement the `generate` method using functions decorated with `@moto_id`.
+    This will ensure that your resource can be assigned a custom id.
+    """
+
+    service: str
+    resource: str
+
+    def __init__(self, account_id: str, region: str, name: str):
+        self.account_id = account_id
+        self.region = region
+        self.name = name or ""
+
+    @abc.abstractmethod
+    def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str:
+        """Method to generate a resource id"""
+
+    @property
+    def unique_identifier(self) -> str:
+        return ".".join(
+            [self.account_id, self.region, self.service, self.resource, self.name]
+        )
+
+    def __str__(self) -> str:
+        return self.unique_identifier
+
+
+class MotoIdManager:
+    """class to manage custom ids. Do not create instance and instead
+    use the `id_manager` instance created below."""
+
+    _custom_ids: Dict[str, str]
+    _id_sources: List[Callable[[IdSourceContext], Union[str, None]]]
+
+    _lock: threading.RLock
+
+    def __init__(self) -> None:
+        self._custom_ids = {}
+        self._lock = threading.RLock()
+        self._id_sources = []
+
+        self.add_id_source(self.get_id_from_tags)
+        self.add_id_source(self.get_custom_id_from_context)
+
+    def get_custom_id(
+        self, resource_identifier: ResourceIdentifier
+    ) -> Union[str, None]:
+        # retrieves a custom_id for a resource. Returns None
+        return self._custom_ids.get(resource_identifier.unique_identifier)
+
+    def set_custom_id(
+        self, resource_identifier: ResourceIdentifier, custom_id: str
+    ) -> None:
+        # Do not set a custom_id for a resource no value was found for the name
+        if not resource_identifier.name:
+            return
+        with self._lock:
+            self._custom_ids[resource_identifier.unique_identifier] = custom_id
+
+    def unset_custom_id(self, resource_identifier: ResourceIdentifier) -> None:
+        # removes a set custom_id for a resource
+        with self._lock:
+            self._custom_ids.pop(resource_identifier.unique_identifier, None)
+
+    def add_id_source(
+        self, id_source: Callable[[IdSourceContext], Union[str, None]]
+    ) -> None:
+        self._id_sources.append(id_source)
+
+    @staticmethod
+    def get_id_from_tags(id_source_context: IdSourceContext) -> Union[str, None]:
+        if tags := id_source_context.get("tags"):
+            return tags.get(TAG_KEY_CUSTOM_ID)
+
+        return None
+
+    def get_custom_id_from_context(
+        self, id_source_context: IdSourceContext
+    ) -> Union[str, None]:
+        # retrieves a custom_id for a resource. Returns None
+        if resource_identifier := id_source_context.get("resource_identifier"):
+            return self.get_custom_id(resource_identifier)
+        return None
+
+    def find_id_from_sources(
+        self, id_source_context: IdSourceContext
+    ) -> Union[str, None]:
+        existing_ids = id_source_context.get("existing_ids") or []
+        for id_source in self._id_sources:
+            if found_id := id_source(id_source_context):
+                if found_id in existing_ids:
+                    log.debug(
+                        f"Found id {found_id} for resource {id_source_context.get('resource_identifier')}, "
+                        "but a resource already exists with this id."
+                    )
+                else:
+                    return found_id
+
+        return None
+
+
+moto_id_manager = MotoIdManager()
+
+
+def moto_id(fn: Callable[..., str]) -> Callable[..., str]:
+    """
+    Decorator for helping in creation of static ids.
+
+    The decorated function should accept the following parameters
+
+    :param resource_identifier
+    :param existing_ids
+        If provided, we will omit returning a custom id if it is already on the list
+    :param tags
+        If provided will look for a tag named `_custom_id_`. This will take precedence over registered custom ids
+    """
+
+    def _wrapper(
+        resource_identifier: ResourceIdentifier,
+        existing_ids: ExistingIds = None,
+        tags: Tags = None,
+        **kwargs: Dict[str, Any],
+    ) -> str:
+        if resource_identifier and (
+            found_id := moto_id_manager.find_id_from_sources(
+                IdSourceContext(
+                    resource_identifier=resource_identifier,
+                    existing_ids=existing_ids,
+                    tags=tags,
+                )
+            )
+        ):
+            return found_id
+
+        return fn(
+            resource_identifier=resource_identifier,
+            existing_ids=existing_ids,
+            tags=tags,
+            **kwargs,
+        )
+
+    return _wrapper
+
+
+@moto_id
+def generate_str_id(  # type: ignore
+    resource_identifier: ResourceIdentifier,
+    existing_ids: ExistingIds = None,
+    tags: Tags = None,
+    length: int = 20,
+    include_digits: bool = True,
+    lower_case: bool = False,
+) -> str:
+    return mock_random.get_random_string(length, include_digits, lower_case)
diff --git a/moto/workspacesweb/models.py b/moto/workspacesweb/models.py
index bd9e80723..a06c5b0d2 100644
--- a/moto/workspacesweb/models.py
+++ b/moto/workspacesweb/models.py
@@ -8,6 +8,8 @@ from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
 from moto.utilities.utils import get_partition
 
+from ..utilities.tagging_service import TaggingService
+
 
 class FakeUserSettings(BaseModel):
     def __init__(
@@ -23,7 +25,6 @@ class FakeUserSettings(BaseModel):
         idle_disconnect_timeout_in_minutes: int,
         paste_allowed: bool,
         print_allowed: bool,
-        tags: Dict[str, str],
         upload_allowed: bool,
         region_name: str,
         account_id: str,
@@ -41,7 +42,6 @@ class FakeUserSettings(BaseModel):
         self.idle_disconnect_timeout_in_minutes = idle_disconnect_timeout_in_minutes
         self.paste_allowed = paste_allowed if paste_allowed else "Disabled"
         self.print_allowed = print_allowed if print_allowed else "Disabled"
-        self.tags = tags
         self.upload_allowed = upload_allowed if upload_allowed else "Disabled"
         self.associated_portal_arns: List[str] = []
 
@@ -62,7 +62,6 @@ class FakeUserSettings(BaseModel):
             "idleDisconnectTimeoutInMinutes": self.idle_disconnect_timeout_in_minutes,
             "pasteAllowed": self.paste_allowed,
             "printAllowed": self.print_allowed,
-            "tags": self.tags,
             "uploadAllowed": self.upload_allowed,
             "userSettingsArn": self.arn,
         }
@@ -73,7 +72,6 @@ class FakeUserAccessLoggingSettings(BaseModel):
         self,
         client_token: str,
         kinesis_stream_arn: str,
-        tags: Dict[str, str],
         region_name: str,
         account_id: str,
     ):
@@ -83,7 +81,6 @@ class FakeUserAccessLoggingSettings(BaseModel):
         )
         self.client_token = client_token
         self.kinesis_stream_arn = kinesis_stream_arn
-        self.tags = tags
         self.associated_portal_arns: List[str] = []
 
     def arn_formatter(self, _id: str, account_id: str, region_name: str) -> str:
@@ -93,7 +90,6 @@ class FakeUserAccessLoggingSettings(BaseModel):
         return {
             "associatedPortalArns": self.associated_portal_arns,
             "kinesisStreamArn": self.kinesis_stream_arn,
-            "tags": self.tags,
             "userAccessLoggingSettingsArn": self.arn,
         }
 
@@ -103,7 +99,6 @@ class FakeNetworkSettings(BaseModel):
         self,
         security_group_ids: List[str],
         subnet_ids: List[str],
-        tags: Dict[str, str],
         vpc_id: str,
         region_name: str,
         account_id: str,
@@ -112,7 +107,6 @@ class FakeNetworkSettings(BaseModel):
         self.arn = self.arn_formatter(self.network_settings_id, account_id, region_name)
         self.security_group_ids = security_group_ids
         self.subnet_ids = subnet_ids
-        self.tags = tags
         self.vpc_id = vpc_id
         self.associated_portal_arns: List[str] = []
 
@@ -125,7 +119,6 @@ class FakeNetworkSettings(BaseModel):
             "networkSettingsArn": self.arn,
             "securityGroupIds": self.security_group_ids,
             "subnetIds": self.subnet_ids,
-            "Tags": self.tags,
             "vpcId": self.vpc_id,
         }
 
@@ -137,7 +130,6 @@ class FakeBrowserSettings(BaseModel):
         browser_policy: str,
         client_token: str,
         customer_managed_key: str,
-        tags: Dict[str, str],
         region_name: str,
         account_id: str,
     ):
@@ -147,7 +139,6 @@ class FakeBrowserSettings(BaseModel):
         self.browser_policy = browser_policy
         self.client_token = client_token
         self.customer_managed_key = customer_managed_key
-        self.tags = tags
         self.associated_portal_arns: List[str] = []
 
     def arn_formatter(self, _id: str, account_id: str, region_name: str) -> str:
@@ -160,7 +151,6 @@ class FakeBrowserSettings(BaseModel):
             "additionalEncryptionContext": self.additional_encryption_context,
             "browserPolicy": self.browser_policy,
             "customerManagedKey": self.customer_managed_key,
-            "tags": self.tags,
         }
 
 
@@ -174,7 +164,6 @@ class FakePortal(BaseModel):
         display_name: str,
         instance_type: str,
         max_concurrent_sessions: str,
-        tags: Dict[str, str],
         region_name: str,
         account_id: str,
     ):
@@ -187,7 +176,6 @@ class FakePortal(BaseModel):
         self.display_name = display_name
         self.instance_type = instance_type
         self.max_concurrent_sessions = max_concurrent_sessions
-        self.tags = tags
         self.portal_endpoint = f"{self.portal_id}.portal.aws"
         self.browser_type = "Chrome"
         self.creation_time = datetime.datetime.now().isoformat()
@@ -225,7 +213,6 @@ class FakePortal(BaseModel):
             "trustStoreArn": self.trust_store_arn,
             "userAccessLoggingSettingsArn": self.user_access_logging_settings_arn,
             "userSettingsArn": self.user_settings_arn,
-            "tags": self.tags,
         }
 
 
@@ -239,6 +226,7 @@ class WorkSpacesWebBackend(BaseBackend):
         self.user_settings: Dict[str, FakeUserSettings] = {}
         self.user_access_logging_settings: Dict[str, FakeUserAccessLoggingSettings] = {}
         self.portals: Dict[str, FakePortal] = {}
+        self.tagger = TaggingService()
 
     def create_network_settings(
         self,
@@ -250,12 +238,13 @@ class WorkSpacesWebBackend(BaseBackend):
         network_settings_object = FakeNetworkSettings(
             security_group_ids,
             subnet_ids,
-            tags,
             vpc_id,
             self.region_name,
             self.account_id,
         )
         self.network_settings[network_settings_object.arn] = network_settings_object
+        if tags:
+            self.tag_resource("TEMP_CLIENT_TOKEN", network_settings_object.arn, tags)
         return network_settings_object.arn
 
     def list_network_settings(self) -> List[Dict[str, str]]:
@@ -276,18 +265,19 @@ class WorkSpacesWebBackend(BaseBackend):
         browser_policy: str,
         client_token: str,
         customer_managed_key: str,
-        tags: Dict[str, str],
+        tags: Optional[List[Dict[str, str]]] = None,
     ) -> str:
         browser_settings_object = FakeBrowserSettings(
             additional_encryption_context,
             browser_policy,
             client_token,
             customer_managed_key,
-            tags,
             self.region_name,
             self.account_id,
         )
         self.browser_settings[browser_settings_object.arn] = browser_settings_object
+        if tags:
+            self.tag_resource(client_token, browser_settings_object.arn, tags)
         return browser_settings_object.arn
 
     def list_browser_settings(self) -> List[Dict[str, str]]:
@@ -311,7 +301,7 @@ class WorkSpacesWebBackend(BaseBackend):
         display_name: str,
         instance_type: str,
         max_concurrent_sessions: str,
-        tags: Dict[str, str],
+        tags: Optional[List[Dict[str, str]]] = None,
     ) -> Tuple[str, str]:
         portal_object = FakePortal(
             additional_encryption_context,
@@ -321,11 +311,12 @@ class WorkSpacesWebBackend(BaseBackend):
             display_name,
             instance_type,
             max_concurrent_sessions,
-            tags,
             self.region_name,
             self.account_id,
         )
         self.portals[portal_object.arn] = portal_object
+        if tags:
+            self.tag_resource(client_token, portal_object.arn, tags)
         return portal_object.arn, portal_object.portal_endpoint
 
     def list_portals(self) -> List[Dict[str, Any]]:
@@ -405,12 +396,13 @@ class WorkSpacesWebBackend(BaseBackend):
             idle_disconnect_timeout_in_minutes,
             paste_allowed,
             print_allowed,
-            tags,
             upload_allowed,
             self.region_name,
             self.account_id,
         )
         self.user_settings[user_settings_object.arn] = user_settings_object
+        if tags:
+            self.tag_resource(client_token, user_settings_object.arn, tags)
         return user_settings_object.arn
 
     def get_user_settings(self, user_settings_arn: str) -> Dict[str, Any]:
@@ -423,11 +415,15 @@ class WorkSpacesWebBackend(BaseBackend):
         self, client_token: Any, kinesis_stream_arn: Any, tags: Any
     ) -> str:
         user_access_logging_settings_object = FakeUserAccessLoggingSettings(
-            client_token, kinesis_stream_arn, tags, self.region_name, self.account_id
+            client_token, kinesis_stream_arn, self.region_name, self.account_id
         )
         self.user_access_logging_settings[user_access_logging_settings_object.arn] = (
             user_access_logging_settings_object
         )
+        if tags:
+            self.tag_resource(
+                client_token, user_access_logging_settings_object.arn, tags
+            )
         return user_access_logging_settings_object.arn
 
     def get_user_access_logging_settings(
@@ -476,5 +472,18 @@ class WorkSpacesWebBackend(BaseBackend):
             for user_access_logging_settings in self.user_access_logging_settings.values()
         ]
 
+    def tag_resource(self, client_token: str, resource_arn: str, tags: Any) -> None:
+        self.tagger.tag_resource(resource_arn, tags)
+
+    def untag_resource(self, resource_arn: str, tag_keys: Any) -> None:
+        self.tagger.untag_resource_using_names(resource_arn, tag_keys)
+
+    def list_tags_for_resource(self, resource_arn: str) -> List[Dict[str, str]]:
+        tags = self.tagger.get_tag_dict_for_resource(resource_arn)
+        Tags = []
+        for key, value in tags.items():
+            Tags.append({"Key": key, "Value": value})
+        return Tags
+
 
 workspacesweb_backends = BackendDict(WorkSpacesWebBackend, "workspaces-web")
diff --git a/moto/workspacesweb/responses.py b/moto/workspacesweb/responses.py
index a9655412e..0878b99ba 100644
--- a/moto/workspacesweb/responses.py
+++ b/moto/workspacesweb/responses.py
@@ -338,3 +338,30 @@ class WorkSpacesWebResponse(BaseResponse):
             self.workspacesweb_backend.list_user_access_logging_settings()
         )
         return json.dumps(dict(userAccessLoggingSettings=user_access_logging_settings))
+
+    def tag_resource(self) -> str:
+        client_token = self._get_param("clientToken")
+        resource_arn = unquote(self._get_param("resourceArn"))
+        tags = self._get_param("tags")
+        self.workspacesweb_backend.tag_resource(
+            client_token=client_token,
+            resource_arn=resource_arn,
+            tags=tags,
+        )
+        return json.dumps(dict())
+
+    def untag_resource(self) -> str:
+        tagKeys = self.__dict__["data"]["tagKeys"]
+        resource_arn = unquote(self._get_param("resourceArn"))
+        self.workspacesweb_backend.untag_resource(
+            resource_arn=resource_arn,
+            tag_keys=tagKeys,
+        )
+        return json.dumps(dict())
+
+    def list_tags_for_resource(self) -> str:
+        resource_arn = unquote(self.parsed_url.path.split("/tags/")[-1])
+        tags = self.workspacesweb_backend.list_tags_for_resource(
+            resource_arn=resource_arn,
+        )
+        return json.dumps(dict(tags=tags))
diff --git a/moto/workspacesweb/urls.py b/moto/workspacesweb/urls.py
index 8c2fb156a..bb3df663f 100644
--- a/moto/workspacesweb/urls.py
+++ b/moto/workspacesweb/urls.py
@@ -21,4 +21,5 @@ url_paths = {
     "{0}/portals/(?P<portalArn>.*)/networkSettings$": WorkSpacesWebResponse.dispatch,
     "{0}/portals/(?P<portalArn>.*)/userSettings$": WorkSpacesWebResponse.dispatch,
     "{0}/portals/(?P<portalArn>.*)/userAccessLoggingSettings$": WorkSpacesWebResponse.dispatch,
+    "{0}/tags/(?P<resourceArn>.+)$": WorkSpacesWebResponse.dispatch,
 }
diff --git a/setup.py b/setup.py
index e402fb4b6..4ed919a8c 100644
--- a/setup.py
+++ b/setup.py
@@ -25,7 +25,7 @@ class CompressJsonCommand(Command):
                 print(f"Compressing {file} into {target_filename}...")  # noqa
                 with open(file, "rb") as source:
                     with open(target_filename, "wb") as target:
-                        target.write(gzip.compress(source.read()))
+                        target.write(gzip.compress(source.read(), mtime=0))
                 print(f"Removing {file}...")  # noqa
                 Path(file).unlink()
 


Test patch:
diff --git a/.github/workflows/tests_decoratormode.yml b/.github/workflows/tests_decoratormode.yml
index 8cdc43aba..bfbe19203 100644
--- a/.github/workflows/tests_decoratormode.yml
+++ b/.github/workflows/tests_decoratormode.yml
@@ -7,7 +7,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13.0-rc.1"]
+        python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
 
     steps:
     - uses: actions/checkout@v4
diff --git a/.github/workflows/tests_sdk_js.yml b/.github/workflows/tests_sdk_js.yml
new file mode 100644
index 000000000..64dc93c8e
--- /dev/null
+++ b/.github/workflows/tests_sdk_js.yml
@@ -0,0 +1,32 @@
+name: JS SDK test
+on: [workflow_call]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        fetch-depth: 0
+    - name: Set up Python 3.11
+      uses: actions/setup-python@v5
+      with:
+        python-version: "3.11"
+    - name: Start MotoServer
+      run: |
+        pip install build
+        python -m build
+        docker run --rm -t --name motoserver -e TEST_SERVER_MODE=true -e AWS_SECRET_ACCESS_KEY=server_secret -e AWS_ACCESS_KEY_ID=server_key -v `pwd`:/moto -p 5000:5000 -v /var/run/docker.sock:/var/run/docker.sock python:3.11-slim /moto/scripts/ci_moto_server.sh &
+        python scripts/ci_wait_for_server.py
+    - uses: actions/setup-node@v4
+    - name: Install dependencies
+      run: |
+        pwd
+        ls -la
+        cd other_langs/tests_js && npm install
+    - name: Run tests
+      run: |
+        pwd
+        ls -la
+        cd other_langs/tests_js && npm test
diff --git a/other_langs/tests_js/ec2.test.js b/other_langs/tests_js/ec2.test.js
new file mode 100644
index 000000000..6fbb83792
--- /dev/null
+++ b/other_langs/tests_js/ec2.test.js
@@ -0,0 +1,15 @@
+import { EC2Client, RunInstancesCommand } from '@aws-sdk/client-ec2'
+
+process.env['AWS_ACCESS_KEY_ID'] = 'test'
+process.env['AWS_SECRET_ACCESS_KEY'] = 'test'
+
+const client = new EC2Client({endpoint:'http://localhost:5000', region: 'us-east-1'});
+
+const command = new RunInstancesCommand({
+    InstanceType: 't2.nano',
+    ImageId: 'ami-0001',
+    MinCount: 1,
+    MaxCount: 1,
+});
+
+const { Instances } = await client.send(command);
diff --git a/other_langs/tests_js/package.json b/other_langs/tests_js/package.json
new file mode 100644
index 000000000..242dc288f
--- /dev/null
+++ b/other_langs/tests_js/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "aws-sdk-v3-examples",
+  "version": "1.0.0",
+  "main": "index.js",
+  "dependencies": {
+    "@aws-sdk/client-ec2": "^3.32.0",
+    "@aws-sdk/client-rds": "^3.32.0",
+    "@aws-sdk/client-s3": "^3.32.0"
+  },
+  "type": "module",
+  "scripts": {
+    "test": "node --test *.test.js"
+  }
+}
diff --git a/other_langs/tests_js/rds.test.js b/other_langs/tests_js/rds.test.js
new file mode 100644
index 000000000..44d812f97
--- /dev/null
+++ b/other_langs/tests_js/rds.test.js
@@ -0,0 +1,19 @@
+import { CreateDBInstanceCommand, DescribeDBInstancesCommand, RDSClient } from '@aws-sdk/client-rds'
+
+process.env['AWS_ACCESS_KEY_ID'] = 'test'
+process.env['AWS_SECRET_ACCESS_KEY'] = 'test'
+
+const client = new RDSClient({region: 'us-east-1', endpoint:'http://localhost:5000'});
+const create_command = new CreateDBInstanceCommand(
+    {
+        DBInstanceClass: "dbic",
+        DBInstanceIdentifier: "dbii",
+        Engine: "mysql",
+        DBName: "dbn"
+    }
+);
+
+await client.send(create_command);
+
+const command = new DescribeDBInstancesCommand({});
+const res = await client.send(command);
diff --git a/other_langs/tests_js/s3.test.js b/other_langs/tests_js/s3.test.js
new file mode 100644
index 000000000..c62e9d65d
--- /dev/null
+++ b/other_langs/tests_js/s3.test.js
@@ -0,0 +1,13 @@
+import { CreateBucketCommand, S3Client } from '@aws-sdk/client-s3'
+
+process.env['AWS_ACCESS_KEY_ID'] = 'test'
+process.env['AWS_SECRET_ACCESS_KEY'] = 'test'
+
+
+const s3 = new S3Client({
+  region:'us-east-1',
+  endpoint: 'http://localhost:5000',
+  forcePathStyle: true,
+})
+
+s3.send(new CreateBucketCommand({ Bucket: 'asdfqwerasdfqwe3' }))
diff --git a/tests/conftest.py b/tests/conftest.py
index f723cf5f3..3a1f20cb0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,6 +2,7 @@ import boto3
 import pytest
 
 from moto import mock_aws
+from moto.utilities.id_generator import ResourceIdentifier, moto_id_manager
 
 
 @pytest.fixture(scope="function")
@@ -16,3 +17,19 @@ def account_id():
         with mock_aws():
             identity = boto3.client("sts", "us-east-1").get_caller_identity()
             yield identity["Account"]
+
+
+@pytest.fixture
+def set_custom_id():
+    set_ids = []
+
+    def _set_custom_id(resource_identifier: ResourceIdentifier, custom_id):
+        moto_id_manager.set_custom_id(
+            resource_identifier=resource_identifier, custom_id=custom_id
+        )
+        set_ids.append(resource_identifier)
+
+    yield _set_custom_id
+
+    for resource_identifier in set_ids:
+        moto_id_manager.unset_custom_id(resource_identifier)
diff --git a/tests/test_apigateway/test_apigateway.py b/tests/test_apigateway/test_apigateway.py
index 5c325a4e7..ba2ca674e 100644
--- a/tests/test_apigateway/test_apigateway.py
+++ b/tests/test_apigateway/test_apigateway.py
@@ -1555,9 +1555,17 @@ def test_get_api_keys():
     keys = client.get_api_keys(nameQuery="TESTKEY2")["items"]
     assert [key["name"] for key in keys] == ["TESTKEY2"]
 
+    # assert that passing only a prefix works
+    keys = client.get_api_keys(nameQuery="TESTKEY")["items"]
+    assert [key["name"] for key in keys] == ["TESTKEY1", "TESTKEY2"]
+
     keys = client.get_api_keys(nameQuery="TESTKEY3")["items"]
     assert keys == []
 
+    # assert that suffix of a name does not work
+    keys = client.get_api_keys(nameQuery="KEY2")["items"]
+    assert keys == []
+
 
 @mock_aws
 def test_create_api_key():
@@ -1791,6 +1799,9 @@ def test_usage_plan_keys():
     # Query By Name
     plans = client.get_usage_plan_keys(usagePlanId=usage_plan_id, nameQuery=key_name)
     assert len(plans["items"]) == 1
+    # test using only a prefix
+    plans = client.get_usage_plan_keys(usagePlanId=usage_plan_id, nameQuery="test-")
+    assert len(plans["items"]) == 1
     plans = client.get_usage_plan_keys(usagePlanId=usage_plan_id, nameQuery="unknown")
     assert len(plans["items"]) == 0
 
diff --git a/tests/test_apigateway/test_apigateway_custom_ids.py b/tests/test_apigateway/test_apigateway_custom_ids.py
new file mode 100644
index 000000000..1d2debbba
--- /dev/null
+++ b/tests/test_apigateway/test_apigateway_custom_ids.py
@@ -0,0 +1,140 @@
+import boto3
+import pytest
+
+from moto import mock_aws, settings
+from moto.apigateway.utils import (
+    ApigwApiKeyIdentifier,
+    ApigwDeploymentIdentifier,
+    ApigwModelIdentifier,
+    ApigwRequestValidatorIdentifier,
+    ApigwResourceIdentifier,
+    ApigwRestApiIdentifier,
+    ApigwUsagePlanIdentifier,
+)
+
+API_ID = "ApiId"
+API_KEY_ID = "ApiKeyId"
+DEPLOYMENT_ID = "DeployId"
+MODEL_ID = "ModelId"
+PET_1_RESOURCE_ID = "Pet1Id"
+PET_2_RESOURCE_ID = "Pet2Id"
+REQUEST_VALIDATOR_ID = "ReqValId"
+ROOT_RESOURCE_ID = "RootId"
+USAGE_PLAN_ID = "UPlanId"
+
+
+@mock_aws
+@pytest.mark.skipif(
+    not settings.TEST_DECORATOR_MODE, reason="Can't access the id manager in proxy mode"
+)
+def test_custom_id_rest_api(set_custom_id, account_id):
+    region_name = "us-west-2"
+    rest_api_name = "my-api"
+    model_name = "modelName"
+    request_validator_name = "request-validator-name"
+    stage_name = "stage-name"
+
+    client = boto3.client("apigateway", region_name=region_name)
+
+    set_custom_id(
+        ApigwRestApiIdentifier(account_id, region_name, rest_api_name), API_ID
+    )
+    set_custom_id(
+        ApigwResourceIdentifier(account_id, region_name, path_name="/"),
+        ROOT_RESOURCE_ID,
+    )
+    set_custom_id(
+        ApigwResourceIdentifier(
+            account_id, region_name, parent_id=ROOT_RESOURCE_ID, path_name="pet"
+        ),
+        PET_1_RESOURCE_ID,
+    )
+    set_custom_id(
+        ApigwResourceIdentifier(
+            account_id, region_name, parent_id=PET_1_RESOURCE_ID, path_name="pet"
+        ),
+        PET_2_RESOURCE_ID,
+    )
+    set_custom_id(ApigwModelIdentifier(account_id, region_name, model_name), MODEL_ID)
+    set_custom_id(
+        ApigwRequestValidatorIdentifier(
+            account_id, region_name, request_validator_name
+        ),
+        REQUEST_VALIDATOR_ID,
+    )
+    set_custom_id(
+        ApigwDeploymentIdentifier(account_id, region_name, stage_name=stage_name),
+        DEPLOYMENT_ID,
+    )
+
+    rest_api = client.create_rest_api(name=rest_api_name)
+    assert rest_api["id"] == API_ID
+    assert rest_api["rootResourceId"] == ROOT_RESOURCE_ID
+
+    pet_resource_1 = client.create_resource(
+        restApiId=API_ID, parentId=ROOT_RESOURCE_ID, pathPart="pet"
+    )
+    assert pet_resource_1["id"] == PET_1_RESOURCE_ID
+
+    # we create a second resource with the same path part to ensure we can pass different ids
+    pet_resource_2 = client.create_resource(
+        restApiId=API_ID, parentId=PET_1_RESOURCE_ID, pathPart="pet"
+    )
+    assert pet_resource_2["id"] == PET_2_RESOURCE_ID
+
+    model = client.create_model(
+        restApiId=API_ID,
+        name=model_name,
+        schema="EMPTY",
+        contentType="application/json",
+    )
+    assert model["id"] == MODEL_ID
+
+    request_validator = client.create_request_validator(
+        restApiId=API_ID, name=request_validator_name
+    )
+    assert request_validator["id"] == REQUEST_VALIDATOR_ID
+
+    # Creating the resource to make a deployment
+    client.put_method(
+        restApiId=API_ID,
+        httpMethod="ANY",
+        resourceId=PET_2_RESOURCE_ID,
+        authorizationType="NONE",
+    )
+    client.put_integration(
+        restApiId=API_ID, resourceId=PET_2_RESOURCE_ID, httpMethod="ANY", type="MOCK"
+    )
+    deployment = client.create_deployment(restApiId=API_ID, stageName=stage_name)
+    assert deployment["id"] == DEPLOYMENT_ID
+
+
+@mock_aws
+@pytest.mark.skipif(
+    not settings.TEST_DECORATOR_MODE, reason="Can't access the id manager in proxy mode"
+)
+def test_custom_id_api_key(account_id, set_custom_id):
+    region_name = "us-west-2"
+    api_key_value = "01234567890123456789"
+    usage_plan_name = "usage-plan"
+
+    client = boto3.client("apigateway", region_name=region_name)
+
+    set_custom_id(
+        ApigwApiKeyIdentifier(account_id, region_name, value=api_key_value), API_KEY_ID
+    )
+    set_custom_id(
+        ApigwUsagePlanIdentifier(account_id, region_name, usage_plan_name),
+        USAGE_PLAN_ID,
+    )
+
+    api_key = client.create_api_key(name="api-key", value=api_key_value)
+    usage_plan = client.create_usage_plan(name=usage_plan_name)
+
+    # verify that we can create a usage plan key using the custom ids
+    client.create_usage_plan_key(
+        usagePlanId=USAGE_PLAN_ID, keyId=API_KEY_ID, keyType="API_KEY"
+    )
+
+    assert api_key["id"] == API_KEY_ID
+    assert usage_plan["id"] == USAGE_PLAN_ID
diff --git a/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py b/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
index 05e656675..fc73f210d 100644
--- a/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
+++ b/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
@@ -5,6 +5,7 @@ import pytest
 from botocore.client import ClientError
 
 from moto import mock_aws
+from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 from tests import EXAMPLE_AMI_ID, EXAMPLE_AMI_ID2
 from tests.test_ds.test_ds_simple_ad_directory import create_test_directory
 
@@ -1175,3 +1176,30 @@ def test_get_resources_efs():
     assert fs_two["FileSystemArn"] in returned_arns
     assert ap_one["AccessPointArn"] not in returned_arns
     assert ap_two["AccessPointArn"] in returned_arns
+
+
+@mock_aws
+def test_get_resources_stepfunction():
+    simple_definition = (
+        '{"Comment": "An example of the Amazon States Language using a choice state.",'
+        '"StartAt": "DefaultState",'
+        '"States": '
+        '{"DefaultState": {"Type": "Fail","Error": "DefaultStateError","Cause": "No Matches!"}}}'
+    )
+    role_arn = "arn:aws:iam::" + ACCOUNT_ID + ":role/unknown_sf_role"
+
+    client = boto3.client("stepfunctions", region_name="us-east-1")
+    client.create_state_machine(
+        name="name1",
+        definition=str(simple_definition),
+        roleArn=role_arn,
+        tags=[{"key": "Name", "value": "Alice"}],
+    )
+
+    rtapi = boto3.client("resourcegroupstaggingapi", region_name="us-east-1")
+    resp = rtapi.get_resources(ResourceTypeFilters=["states:stateMachine"])
+
+    assert len(resp["ResourceTagMappingList"]) == 1
+    assert {"Key": "Name", "Value": "Alice"} in resp["ResourceTagMappingList"][0][
+        "Tags"
+    ]
diff --git a/tests/test_secretsmanager/test_secretsmanager.py b/tests/test_secretsmanager/test_secretsmanager.py
index e386aff25..734fcf384 100644
--- a/tests/test_secretsmanager/test_secretsmanager.py
+++ b/tests/test_secretsmanager/test_secretsmanager.py
@@ -13,6 +13,7 @@ from freezegun import freeze_time
 
 from moto import mock_aws, settings
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
+from moto.secretsmanager.utils import SecretsManagerSecretIdentifier
 
 from . import secretsmanager_aws_verified
 
@@ -628,6 +629,17 @@ def test_delete_secret_that_is_marked_deleted():
         conn.delete_secret(SecretId="test-secret")
 
 
+@mock_aws
+def test_force_delete_secret_that_is_marked_deleted():
+    conn = boto3.client("secretsmanager", region_name="us-west-2")
+
+    conn.create_secret(Name="test-secret", SecretString="foosecret")
+
+    conn.delete_secret(SecretId="test-secret")
+
+    conn.delete_secret(SecretId="test-secret", ForceDeleteWithoutRecovery=True)
+
+
 @mock_aws
 def test_get_random_password_default_length():
     conn = boto3.client("secretsmanager", region_name="us-west-2")
@@ -1950,3 +1962,23 @@ def test_update_secret_version_stage_dont_specify_current_stage(secret_arn=None)
         err["Message"]
         == f"The parameter RemoveFromVersionId can't be empty. Staging label AWSCURRENT is currently attached to version {current_version}, so you must explicitly reference that version in RemoveFromVersionId."
     )
+
+
+@mock_aws
+@pytest.mark.skipif(
+    not settings.TEST_DECORATOR_MODE, reason="Can't access the id manager in proxy mode"
+)
+def test_create_secret_custom_id(account_id, set_custom_id):
+    secret_suffix = "randomSuffix"
+    secret_name = "secret-name"
+    region_name = "us-east-1"
+
+    client = boto3.client("secretsmanager", region_name=region_name)
+
+    set_custom_id(
+        SecretsManagerSecretIdentifier(account_id, region_name, secret_name),
+        secret_suffix,
+    )
+    secret = client.create_secret(Name=secret_name, SecretString="my secret")
+
+    assert secret["ARN"].split(":")[-1] == f"{secret_name}-{secret_suffix}"
diff --git a/tests/test_utilities/test_id_generator.py b/tests/test_utilities/test_id_generator.py
new file mode 100644
index 000000000..7ee85fd32
--- /dev/null
+++ b/tests/test_utilities/test_id_generator.py
@@ -0,0 +1,134 @@
+from moto.utilities.id_generator import (
+    TAG_KEY_CUSTOM_ID,
+    ExistingIds,
+    ResourceIdentifier,
+    Tags,
+    moto_id,
+    moto_id_manager,
+)
+
+ACCOUNT = "account"
+REGION = "us-east-1"
+RESOURCE_NAME = "my-resource"
+
+CUSTOM_ID = "custom"
+GENERATED_ID = "generated"
+TAG_ID = "fromTag"
+SERVICE = "test-service"
+RESOURCE = "test-resource"
+
+
+@moto_id
+def generate_test_id(
+    resource_identifier: ResourceIdentifier,
+    existing_ids: ExistingIds = None,
+    tags: Tags = None,
+):
+    return GENERATED_ID
+
+
+class TestResourceIdentifier(ResourceIdentifier):
+    service = SERVICE
+    resource = RESOURCE
+
+    def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str:
+        return generate_test_id(
+            resource_identifier=self, existing_ids=existing_ids, tags=tags
+        )
+
+
+def test_generate_with_no_resource_identifier():
+    generated_id = generate_test_id(None)
+    assert generated_id == GENERATED_ID
+
+
+def test_generate_with_matching_resource_identifier(set_custom_id):
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    set_custom_id(resource_identifier, CUSTOM_ID)
+
+    generated_id = generate_test_id(resource_identifier=resource_identifier)
+    assert generated_id == CUSTOM_ID
+
+
+def test_generate_with_non_matching_resource_identifier(set_custom_id):
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+    resource_identifier_2 = TestResourceIdentifier(ACCOUNT, REGION, "non-matching")
+
+    set_custom_id(resource_identifier, CUSTOM_ID)
+
+    generated_id = generate_test_id(resource_identifier=resource_identifier_2)
+    assert generated_id == GENERATED_ID
+
+
+def test_generate_with_custom_id_tag():
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    generated_id = generate_test_id(
+        resource_identifier=resource_identifier, tags={TAG_KEY_CUSTOM_ID: TAG_ID}
+    )
+    assert generated_id == TAG_ID
+
+
+def test_generate_with_custom_id_tag_has_priority(set_custom_id):
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    set_custom_id(resource_identifier, CUSTOM_ID)
+    generated_id = generate_test_id(
+        resource_identifier=resource_identifier, tags={TAG_KEY_CUSTOM_ID: TAG_ID}
+    )
+    assert generated_id == TAG_ID
+
+
+def test_generate_with_existing_id(set_custom_id):
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    set_custom_id(resource_identifier, CUSTOM_ID)
+    generated_id = generate_test_id(
+        resource_identifier=resource_identifier, existing_ids=[CUSTOM_ID]
+    )
+    assert generated_id == GENERATED_ID
+
+
+def test_generate_with_tags_and_existing_id(set_custom_id):
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    generated_id = generate_test_id(
+        resource_identifier=resource_identifier,
+        existing_ids=[TAG_ID],
+        tags={TAG_KEY_CUSTOM_ID: TAG_ID},
+    )
+    assert generated_id == GENERATED_ID
+
+
+def test_generate_with_tags_fallback(set_custom_id):
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    set_custom_id(resource_identifier, CUSTOM_ID)
+    generated_id = generate_test_id(
+        resource_identifier=resource_identifier,
+        existing_ids=[TAG_ID],
+        tags={TAG_KEY_CUSTOM_ID: TAG_ID},
+    )
+    assert generated_id == CUSTOM_ID
+
+
+def test_set_custom_id_lifecycle():
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, RESOURCE_NAME)
+
+    moto_id_manager.set_custom_id(resource_identifier, CUSTOM_ID)
+
+    found_id = moto_id_manager.get_custom_id(resource_identifier)
+    assert found_id == CUSTOM_ID
+
+    moto_id_manager.unset_custom_id(resource_identifier)
+
+    found_id = moto_id_manager.get_custom_id(resource_identifier)
+    assert found_id is None
+
+
+def test_set_custom_id_name_is_not_set():
+    resource_identifier = TestResourceIdentifier(ACCOUNT, REGION, None)
+    moto_id_manager.set_custom_id(resource_identifier, CUSTOM_ID)
+
+    assert moto_id_manager._custom_ids == {}
diff --git a/tests/test_workspacesweb/test_workspacesweb.py b/tests/test_workspacesweb/test_workspacesweb.py
index 47a46c26f..ef5f570de 100644
--- a/tests/test_workspacesweb/test_workspacesweb.py
+++ b/tests/test_workspacesweb/test_workspacesweb.py
@@ -13,6 +13,7 @@ FAKE_SECURITY_GROUP_IDS = ["sg-0123456789abcdef0"]
 FAKE_SUBNET_IDS = ["subnet-0123456789abcdef0", "subnet-abcdef0123456789"]
 FAKE_TAGS = [
     {"Key": "TestKey", "Value": "TestValue"},
+    {"Key": "TestKey2", "Value": "TestValue2"},
 ]
 FAKE_VPC_ID = "vpc-0123456789abcdef0"
 FAKE_KMS_KEY_ID = "abcd1234-5678-90ab-cdef-FAKEKEY"
@@ -436,3 +437,80 @@ def test_associate_user_access_logging_settings():
         userAccessLoggingSettingsArn=user_access_logging_settings_arn
     )["userAccessLoggingSettings"]
     assert resp["associatedPortalArns"] == [portal_arn]
+
+
+@mock_aws
+def test_list_tags_for_resource():
+    client = boto3.client("workspaces-web", region_name="ap-southeast-1")
+
+    OTHER_FAKE_TAGS = [
+        {"Key": "FAKEKEY1", "Value": "FAKEVALUE1"},
+        {"Key": "FAKEKEY2", "Value": "FAKEVALUE2"},
+    ]
+
+    arns = []
+
+    arns.append(
+        client.create_portal(
+            additionalEncryptionContext={"Key1": "Encryption", "Key2": "Context"},
+            authenticationType="Standard",
+            clientToken="TestClient",
+            customerManagedKey=FAKE_KMS_KEY_ID,
+            displayName="TestDisplayName",
+            instanceType="TestInstanceType",
+            maxConcurrentSessions=5,
+            tags=FAKE_TAGS,
+        )["portalArn"]
+    )
+
+    arns.append(
+        client.create_network_settings(
+            securityGroupIds=FAKE_SECURITY_GROUP_IDS,
+            subnetIds=FAKE_SUBNET_IDS,
+            tags=FAKE_TAGS,
+            vpcId=FAKE_VPC_ID,
+        )["networkSettingsArn"]
+    )
+
+    arns.append(
+        client.create_user_settings(
+            copyAllowed="Disabled",
+            pasteAllowed="Disabled",
+            printAllowed="Disabled",
+            uploadAllowed="Disabled",
+            downloadAllowed="Disabled",
+            tags=FAKE_TAGS,
+        )["userSettingsArn"]
+    )
+
+    arns.append(
+        client.create_user_access_logging_settings(
+            kinesisStreamArn="arn:aws:kinesis:ap-southeast-1:123456789012:stream/TestStream",
+            tags=FAKE_TAGS,
+        )["userAccessLoggingSettingsArn"]
+    )
+
+    arns.append(
+        client.create_browser_settings(
+            additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+            browserPolicy="TestBrowserPolicy",
+            clientToken="TestClient",
+            customerManagedKey=FAKE_KMS_KEY_ID,
+            tags=FAKE_TAGS,
+        )["browserSettingsArn"]
+    )
+
+    for arn in arns:
+        resp = client.list_tags_for_resource(resourceArn=arn)
+        assert resp["tags"] == FAKE_TAGS
+
+        client.tag_resource(resourceArn=arn, tags=OTHER_FAKE_TAGS)
+        resp = client.list_tags_for_resource(resourceArn=arn)
+        assert resp["tags"] == FAKE_TAGS + OTHER_FAKE_TAGS
+
+        client.untag_resource(resourceArn=arn, tagKeys=["FAKEKEY1", "TestKey"])
+        resp = client.list_tags_for_resource(resourceArn=arn)
+        assert resp["tags"] == [
+            {"Key": "TestKey2", "Value": "TestValue2"},
+            {"Key": "FAKEKEY2", "Value": "FAKEVALUE2"},
+        ]