From 13938ae5997229cf8c4c2d37baf5fbac9900579d Mon Sep 17 00:00:00 2001
From: vasileios <zigras00@gmail.com>
Date: Wed, 2 Oct 2024 09:18:29 +0200
Subject: [PATCH] [#4396] Moved public functions to prefill.service and added
 prefill plugin for ObjectsApi

---
 .../haal_centraal/tests/test_integration.py   |   2 +-
 src/openforms/formio/service.py               |   4 +-
 .../tests/test_component_translations.py      |   5 +-
 ...efill_config_empty_or_complete_and_more.py |  12 +-
 src/openforms/forms/models/form_variable.py   |  10 +-
 .../forms/tests/variables/test_model.py       |  15 +-
 src/openforms/prefill/__init__.py             | 206 -------------
 src/openforms/prefill/base.py                 |  21 ++
 .../prefill/contrib/objects_api/plugin.py     |  30 ++
 ...test_invalid_service_raises_exception.yaml |  75 +++++
 ...tTests.test_list_available_attributes.yaml |   2 +-
 ...inEndpointTests.test_list_objecttypes.yaml |   7 +-
 ...tTests.test_list_objecttypes_versions.yaml |   2 +-
 ...nTests.test_prefill_values_happy_flow.yaml | 108 +++++++
 ...efill_values_when_reference_not_found.yaml |  50 ++++
 ...s_when_reference_returns_empty_values.yaml | 105 +++++++
 ...lled_values_are_updated_in_the_object.yaml | 206 +++++++++++++
 .../contrib/objects_api/tests/test_config.py  |  74 +++++
 .../objects_api/tests/test_endpoints.py       |   9 -
 .../contrib/objects_api/tests/test_prefill.py | 270 ++++++++++++++++++
 src/openforms/prefill/service.py              | 172 +++++++++++
 src/openforms/prefill/sources/__init__.py     |   0
 src/openforms/prefill/sources/component.py    |  78 +++++
 src/openforms/prefill/sources/user_defined.py |  36 +++
 .../prefill/tests/test_prefill_hook.py        |   2 +-
 .../prefill/tests/test_prefill_variables.py   |   7 +-
 src/openforms/prefill/utils.py                |  18 ++
 src/openforms/submissions/api/viewsets.py     |   2 +-
 .../tests/test_start_submission.py            |  22 +-
 .../tests/test_submission_step_validate.py    |   4 +-
 src/openforms/variables/tests/test_views.py   |   2 +
 31 files changed, 1302 insertions(+), 254 deletions(-)
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginConfigTests/ObjectsAPIPrefillPluginConfigTests.test_invalid_service_raises_exception.yaml
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_happy_flow.yaml
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_not_found.yaml
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_returns_empty_values.yaml
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefilled_values_are_updated_in_the_object.yaml
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/test_config.py
 create mode 100644 src/openforms/prefill/contrib/objects_api/tests/test_prefill.py
 create mode 100644 src/openforms/prefill/service.py
 create mode 100644 src/openforms/prefill/sources/__init__.py
 create mode 100644 src/openforms/prefill/sources/component.py
 create mode 100644 src/openforms/prefill/sources/user_defined.py
 create mode 100644 src/openforms/prefill/utils.py

diff --git a/src/openforms/contrib/haal_centraal/tests/test_integration.py b/src/openforms/contrib/haal_centraal/tests/test_integration.py
index b2b663b302..27532ccc22 100644
--- a/src/openforms/contrib/haal_centraal/tests/test_integration.py
+++ b/src/openforms/contrib/haal_centraal/tests/test_integration.py
@@ -8,8 +8,8 @@
 from openforms.authentication.service import AuthAttribute
 from openforms.authentication.utils import store_auth_details, store_registrator_details
 from openforms.config.models import GlobalConfiguration
-from openforms.prefill import prefill_variables
 from openforms.prefill.contrib.haalcentraal_brp.plugin import PLUGIN_IDENTIFIER
+from openforms.prefill.service import prefill_variables
 from openforms.submissions.tests.factories import SubmissionFactory
 from openforms.typing import JSONValue
 from openforms.utils.tests.vcr import OFVCRMixin
diff --git a/src/openforms/formio/service.py b/src/openforms/formio/service.py
index f4bf4d8f9b..f7f2d0bfc7 100644
--- a/src/openforms/formio/service.py
+++ b/src/openforms/formio/service.py
@@ -14,7 +14,6 @@
 import elasticapm
 from rest_framework.request import Request
 
-from openforms.prefill import inject_prefill
 from openforms.submissions.models import Submission
 from openforms.typing import DataMapping
 
@@ -73,6 +72,9 @@ def get_dynamic_configuration(
     The configuration is modified in the context of the provided ``submission``
     parameter.
     """
+    # Avoid circular imports
+    from openforms.prefill.service import inject_prefill
+
     rewrite_formio_components(config_wrapper, submission=submission, data=data)
 
     # Add to each component the custom errors in the current locale
diff --git a/src/openforms/formio/tests/test_component_translations.py b/src/openforms/formio/tests/test_component_translations.py
index cc2a97dd1b..896efbc17b 100644
--- a/src/openforms/formio/tests/test_component_translations.py
+++ b/src/openforms/formio/tests/test_component_translations.py
@@ -28,7 +28,10 @@ def disable_prefill_injection():
     """
     Disable prefill to prevent prefill-related queries.
     """
-    return patch("openforms.formio.service.inject_prefill", new=MagicMock)
+    return patch(
+        "openforms.prefill.service.inject_prefill",
+        new=MagicMock,
+    )
 
 
 TEST_CONFIGURATION = {
diff --git a/src/openforms/forms/migrations/0101_remove_formvariable_prefill_config_empty_or_complete_and_more.py b/src/openforms/forms/migrations/0101_remove_formvariable_prefill_config_empty_or_complete_and_more.py
index eecf2b758b..89fbf95b85 100644
--- a/src/openforms/forms/migrations/0101_remove_formvariable_prefill_config_empty_or_complete_and_more.py
+++ b/src/openforms/forms/migrations/0101_remove_formvariable_prefill_config_empty_or_complete_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.16 on 2024-10-02 07:06
+# Generated by Django 4.2.16 on 2024-10-02 10:03
 
 from django.db import migrations, models
 
@@ -34,19 +34,19 @@ class Migration(migrations.Migration):
                         models.Q(
                             models.Q(("prefill_plugin", ""), _negated=True),
                             ("prefill_attribute", ""),
-                            ("prefill_options", {}),
+                            models.Q(("prefill_options", {}), _negated=True),
                             ("source", "user_defined"),
                         ),
                         models.Q(
                             models.Q(("prefill_plugin", ""), _negated=True),
-                            models.Q(("prefill_attribute", ""), _negated=True),
+                            ("prefill_attribute", ""),
                             ("prefill_options", {}),
+                            models.Q(("source", "user_defined"), _negated=True),
                         ),
                         models.Q(
                             models.Q(("prefill_plugin", ""), _negated=True),
-                            ("prefill_attribute", ""),
-                            models.Q(("prefill_options", {}), _negated=True),
-                            ("source", "user_defined"),
+                            models.Q(("prefill_attribute", ""), _negated=True),
+                            ("prefill_options", {}),
                         ),
                         _connector="OR",
                     )
diff --git a/src/openforms/forms/models/form_variable.py b/src/openforms/forms/models/form_variable.py
index 640c60b476..8c30ce56e9 100644
--- a/src/openforms/forms/models/form_variable.py
+++ b/src/openforms/forms/models/form_variable.py
@@ -216,19 +216,19 @@ class Meta:
                     | (
                         ~EMPTY_PREFILL_PLUGIN
                         & EMPTY_PREFILL_ATTRIBUTE
-                        & EMPTY_PREFILL_OPTIONS
+                        & ~EMPTY_PREFILL_OPTIONS
                         & USER_DEFINED
                     )
                     | (
                         ~EMPTY_PREFILL_PLUGIN
-                        & ~EMPTY_PREFILL_ATTRIBUTE
+                        & EMPTY_PREFILL_ATTRIBUTE
                         & EMPTY_PREFILL_OPTIONS
+                        & ~USER_DEFINED
                     )
                     | (
                         ~EMPTY_PREFILL_PLUGIN
-                        & EMPTY_PREFILL_ATTRIBUTE
-                        & ~EMPTY_PREFILL_OPTIONS
-                        & USER_DEFINED
+                        & ~EMPTY_PREFILL_ATTRIBUTE
+                        & EMPTY_PREFILL_OPTIONS
                     )
                 ),
                 name="prefill_config_component_or_user_defined",
diff --git a/src/openforms/forms/tests/variables/test_model.py b/src/openforms/forms/tests/variables/test_model.py
index 62557c610d..48c98b779c 100644
--- a/src/openforms/forms/tests/variables/test_model.py
+++ b/src/openforms/forms/tests/variables/test_model.py
@@ -32,10 +32,17 @@ def test_prefill_plugin_prefill_attribute_prefill_options_empty(self):
         )
 
     def test_prefill_attribute_prefill_options_empty(self):
-        FormVariableFactory.create(
-            prefill_plugin="demo",
-            prefill_attribute="",
-            prefill_options={},
+        FormStepFactory.create(
+            form_definition__configuration={
+                "components": [
+                    {
+                        "type": "textfield",
+                        "key": "test-key",
+                        "label": "Test label",
+                        "prefill": {"plugin": "demo", "attribute": ""},
+                    }
+                ]
+            }
         )
 
     def test_prefill_options_empty(self):
diff --git a/src/openforms/prefill/__init__.py b/src/openforms/prefill/__init__.py
index 177a8e13b4..e69de29bb2 100644
--- a/src/openforms/prefill/__init__.py
+++ b/src/openforms/prefill/__init__.py
@@ -1,206 +0,0 @@
-"""
-This package holds the base module structure for the pre-fill plugins used in Open Forms.
-
-Various sources exist that can be consulted to fetch data for an active session,
-where the BSN, CoC number... can be used to retrieve this data. Think of pre-filling
-the address details of a person after logging in with DigiD.
-
-The package integrates with the form builder such that it's possible for every form
-field to select which pre-fill plugin to use and which value to use from the fetched
-result. Plugins can be registered using a similar approach to the registrations
-package. Each plugin is responsible for exposing which attributes/data fragments are
-available, and for performing the actual look-up. Plugins receive the
-:class:`openforms.submissions.models.Submission` instance that represents the current
-form session of an end-user.
-
-Prefill values are embedded as default values for form fields, dynamically for every
-user session using the component rewrite functionality in the serializers.
-
-So, to recap:
-
-1. Plugins are defined and registered
-2. When editing form definitions in the admin, content editors can opt-in to pre-fill
-   functionality. They select the desired plugin, and then the desired attribute from
-   that plugin.
-3. End-user starts the form and logs in, thereby creating a session/``Submission``
-4. The submission-specific form definition configuration is enhanced with the pre-filled
-   form field default values.
-
-.. todo:: Move the public API into ``openforms.prefill.service``.
-
-"""
-
-from __future__ import annotations
-
-import logging
-from collections import defaultdict
-from typing import TYPE_CHECKING, Any
-
-import elasticapm
-from glom import Path, PathAccessError, assign, glom
-from zgw_consumers.concurrent import parallel
-
-from openforms.plugins.exceptions import PluginNotEnabled
-from openforms.variables.constants import FormVariableSources
-
-if TYPE_CHECKING:
-    from openforms.formio.service import FormioConfigurationWrapper
-    from openforms.submissions.models import Submission
-
-    from .registry import Registry
-
-logger = logging.getLogger(__name__)
-
-
-@elasticapm.capture_span(span_type="app.prefill")
-def _fetch_prefill_values(
-    grouped_fields: dict[str, dict[str, list[str]]],
-    submission: Submission,
-    register: Registry,
-) -> dict[str, dict[str, Any]]:
-    # local import to prevent AppRegistryNotReady:
-    from openforms.logging import logevent
-
-    @elasticapm.capture_span(span_type="app.prefill")
-    def invoke_plugin(
-        item: tuple[str, str, list[str]]
-    ) -> tuple[str, str, dict[str, Any]]:
-        plugin_id, identifier_role, fields = item
-
-        plugin = register[plugin_id]
-        if not plugin.is_enabled:
-            raise PluginNotEnabled()
-
-        try:
-            values = plugin.get_prefill_values(submission, fields, identifier_role)
-        except Exception as e:
-            logger.exception(f"exception in prefill plugin '{plugin_id}'")
-            logevent.prefill_retrieve_failure(submission, plugin, e)
-            values = {}
-        else:
-            if values:
-                logevent.prefill_retrieve_success(submission, plugin, fields)
-            else:
-                logevent.prefill_retrieve_empty(submission, plugin, fields)
-
-        return plugin_id, identifier_role, values
-
-    invoke_plugin_args = []
-    for plugin_id, field_groups in grouped_fields.items():
-        for identifier_role, fields in field_groups.items():
-            invoke_plugin_args.append((plugin_id, identifier_role, fields))
-
-    with parallel() as executor:
-        results = executor.map(invoke_plugin, invoke_plugin_args)
-
-    collected_results = {}
-    for plugin_id, identifier_role, values_dict in list(results):
-        assign(
-            collected_results,
-            Path(plugin_id, identifier_role),
-            values_dict,
-            missing=dict,
-        )
-
-    return collected_results
-
-
-def inject_prefill(
-    configuration_wrapper: FormioConfigurationWrapper, submission: Submission
-) -> None:
-    """
-    Mutates each component found in configuration according to the prefilled values.
-
-    :param configuration_wrapper: The Formiojs JSON schema wrapper describing an entire
-      form or an individual component within the form.
-    :param submission: The :class:`openforms.submissions.models.Submission` instance
-      that holds the values of the prefill data. The prefill data was fetched earlier,
-      see :func:`prefill_variables`.
-
-    The prefill values are looped over by key: value, and for each value the matching
-    component is looked up to normalize it in the context of the component.
-    """
-    from openforms.formio.service import normalize_value_for_component
-
-    prefilled_data = submission.get_prefilled_data()
-    for key, prefill_value in prefilled_data.items():
-        try:
-            component = configuration_wrapper[key]
-        except KeyError:
-            # The component to prefill is not in this step
-            continue
-
-        if not (prefill := component.get("prefill")):
-            continue
-        if not prefill.get("plugin"):
-            continue
-        if not prefill.get("attribute"):
-            continue
-
-        default_value = component.get("defaultValue")
-        # 1693: we need to normalize values according to the format expected by the
-        # component. For example, (some) prefill plugins return postal codes without
-        # space between the digits and the letters.
-        prefill_value = normalize_value_for_component(component, prefill_value)
-
-        if prefill_value != default_value and default_value is not None:
-            logger.info(
-                "Overwriting non-null default value for component %r",
-                component,
-            )
-        component["defaultValue"] = prefill_value
-
-
-@elasticapm.capture_span(span_type="app.prefill")
-def prefill_variables(submission: Submission, register: Registry | None = None) -> None:
-    """Update the submission variables state with the fetched attribute values.
-
-    For each submission value variable that need to be prefilled, the according plugin will
-    be used to fetch the value. If ``register`` is not specified, the default registry instance
-    will be used.
-    """
-    from openforms.formio.service import normalize_value_for_component
-
-    from .registry import register as default_register
-
-    register = register or default_register
-
-    state = submission.load_submission_value_variables_state()
-    variables_to_prefill = state.get_prefill_variables()
-
-    # grouped_fields is a dict of the following shape:
-    # {"plugin_id": {"identifier_role": ["attr_1", "attr_2"]}}
-    # "identifier_role" is either "main" or "authorizee"
-    grouped_fields: defaultdict[str, defaultdict[str, list[str]]] = defaultdict(
-        lambda: defaultdict(list)
-    )
-    for variable in variables_to_prefill:
-        plugin_id = variable.form_variable.prefill_plugin
-        identifier_role = variable.form_variable.prefill_identifier_role
-        attribute_name = variable.form_variable.prefill_attribute
-
-        grouped_fields[plugin_id][identifier_role].append(attribute_name)
-
-    results = _fetch_prefill_values(grouped_fields, submission, register)
-
-    total_config_wrapper = submission.total_configuration_wrapper
-    prefill_data = {}
-    for variable in variables_to_prefill:
-        try:
-            prefill_value = glom(
-                results,
-                Path(
-                    variable.form_variable.prefill_plugin,
-                    variable.form_variable.prefill_identifier_role,
-                    variable.form_variable.prefill_attribute,
-                ),
-            )
-        except PathAccessError:
-            continue
-        else:
-            if variable.form_variable.source == FormVariableSources.component:
-                component = total_config_wrapper[variable.key]
-                prefill_value = normalize_value_for_component(component, prefill_value)
-            prefill_data[variable.key] = prefill_value
-
-    state.save_prefill_data(prefill_data)
diff --git a/src/openforms/prefill/base.py b/src/openforms/prefill/base.py
index 609c3cb772..b7b989f448 100644
--- a/src/openforms/prefill/base.py
+++ b/src/openforms/prefill/base.py
@@ -1,6 +1,7 @@
 from typing import Any, Container, Iterable
 
 from openforms.authentication.service import AuthAttribute
+from openforms.forms.models import FormVariable
 from openforms.plugins.plugin import AbstractBasePlugin
 from openforms.submissions.models import Submission
 from openforms.typing import JSONEncodable, JSONObject
@@ -50,6 +51,26 @@ def get_prefill_values(
         """
         raise NotImplementedError("You must implement the 'get_prefill_values' method.")
 
+    @classmethod
+    def get_prefill_values_from_mappings(
+        cls, submission: Submission, form_variable: FormVariable
+    ) -> dict[str, str]:
+        """
+        Given the saved form variable, which contains the prefill_options, look up the appropriate
+        values and return them.
+
+        :param submission: an active :class:`Submission` instance, which can supply
+          the required initial data reference to fetch the correct prefill values.
+        :param form_variable: The form variable for which we want to retrieve the data. Its
+        atribute prefill_options contains all the mappings that are needed for retrieving
+        and returning the values.
+        :return: a key-value dictionary, where the key is the mapped property and
+          the value is the prefill value to use for that property.
+        """
+        raise NotImplementedError(
+            "You must implement the 'get_prefill_values_from_mappings' method."
+        )
+
     @classmethod
     def get_co_sign_values(
         cls, submission: Submission, identifier: str
diff --git a/src/openforms/prefill/contrib/objects_api/plugin.py b/src/openforms/prefill/contrib/objects_api/plugin.py
index 5efa02d081..f831da28af 100644
--- a/src/openforms/prefill/contrib/objects_api/plugin.py
+++ b/src/openforms/prefill/contrib/objects_api/plugin.py
@@ -3,13 +3,19 @@
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
+from glom import Path, glom
+
 from openforms.contrib.objects_api.checks import check_config
+from openforms.contrib.objects_api.clients import get_objects_client
 from openforms.contrib.objects_api.models import ObjectsAPIGroupConfig
+from openforms.forms.models import FormVariable
 from openforms.registrations.contrib.objects_api.models import ObjectsAPIConfig
+from openforms.submissions.models import Submission
 from openforms.typing import JSONObject
 
 from ...base import BasePlugin
 from ...registry import register
+from ...utils import find_in_dicts
 
 logger = logging.getLogger(__name__)
 
@@ -20,6 +26,30 @@
 class ObjectsAPIPrefill(BasePlugin):
     verbose_name = _("Objects API")
 
+    @classmethod
+    def get_prefill_values_from_mappings(
+        cls,
+        submission: Submission,
+        form_variable: FormVariable,
+    ) -> dict[str, str]:
+        variables_mappings = form_variable.prefill_options.get("variables_mapping")
+        config_group_id = form_variable.prefill_options.get("objects_api_group")
+        config_group = ObjectsAPIGroupConfig.objects.get(id=config_group_id)
+
+        with get_objects_client(config_group) as client:
+            obj = client.get_object(submission.initial_data_reference)
+
+        obj_record = obj.get("record", {})
+        obj_record_data = glom(obj, "record.data")
+
+        results = {}
+        for mapping in variables_mappings:
+            path = Path(*mapping["target_path"])
+            if value := find_in_dicts(obj_record, obj_record_data, path=path):
+                results[mapping["variable_key"]] = value
+
+        return results
+
     def check_config(self):
         check_config()
 
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginConfigTests/ObjectsAPIPrefillPluginConfigTests.test_invalid_service_raises_exception.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginConfigTests/ObjectsAPIPrefillPluginConfigTests.test_invalid_service_raises_exception.yaml
new file mode 100644
index 0000000000..0931ad8d4c
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginConfigTests/ObjectsAPIPrefillPluginConfigTests.test_invalid_service_raises_exception.yaml
@@ -0,0 +1,75 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token INVALID
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.32.2
+    method: GET
+    uri: http://localhost:8002/api/v2/invalid/objects?pageSize=1
+  response:
+    body:
+      string: "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n
+        \   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1,
+        shrink-to-fit=no\">\n\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"/static/bundles/objects-css.d91e8a41885a.css\">\n
+        \   <link rel=\"stylesheet\" href=\"/static/vng_api_common/libs/fontawesome/css/all.min.8e6bafb03a21.css\">\n\n
+        \   <link rel=\"icon\" type=\"image/png\" href=\"/static/ico/favicon.3b1d8b596615.png\">\n\n
+        \   \n\n    <title>Starting point | Objects</title>\n  </head>\n  <body>\n\n
+        \   \n\n    \n\n  <h1>Sorry, the requested page could not be found (404)</h1>\n\n\n\n\n
+        \   <footer class=\"footer container\">\n      <div class=\"footer__row\">\n
+        \       <div class=\"footer__col footer__col--small\">\n          <img src=\"/static/img/maykin_logo.d6ba51c82254.png\"
+        alt=\"Maykin Media logo\" height=\"48\">\n          <img src=\"/static/img/opengem_logo.32e4cb40dc24.png\"
+        alt=\"Open Gemeente Initiatief logo\" height=\"48\">\n          <p>\n          Developed
+        by <a class=\"link\" href=\"https://www.maykinmedia.nl\">Maykin Media</a><br>\n
+        \         within the <a class=\"link\" href=\"https://opengem.nl\">Open Gemeente
+        Initiatief</a> &copy; 2024<br>\n          commissioned by the <a class=\"link\"
+        href=\"https://www.utrecht.nl\">Municipality of Utrecht</a>\n          </p>\n
+        \       </div>\n        <div class=\"footer__col\">\n          <h5 class=\"footer__header\">Objects
+        API</h5>\n          <ul class=\"footer__list\">\n            <li><a class=\"link
+        link--muted\" href=\"https://objects-and-objecttypes-api.readthedocs.io/\">Documentation</a></li>\n
+        \           <li><a class=\"link link--muted\" href=\"https://commonground.nl/groups/view/54477963/objecten-en-objecttypen-api\">Community
+        on CommonGround.nl</a></li>\n            <li><a class=\"link link--muted\"
+        href=\"https://hub.docker.com/r/maykinmedia/objects-api\">Docker images</a></li>\n
+        \           <li><a class=\"link link--muted\" href=\"https://github.com/maykinmedia/objects-api\">Code
+        on Github</a></li>\n          </ul>\n        </div>\n        <div class=\"footer__col\">\n
+        \         <h5 class=\"footer__header\">Other</h5>\n          <ul class=\"footer__list\">\n
+        \           <li>Report <a class=\"link link--muted\" href=\"https://github.com/maykinmedia/objects-api/issues\">issues</a>
+        for questions, bugs or wishes</li>\n            <li>Read more on <a class=\"link
+        link--muted\" href=\"https://commonground.nl/\">Common Ground</a></li>\n          </ul>\n
+        \       </div>\n      </div>\n\n      <div class=\"footer__row\">\n        <div
+        class=\"footer__col\">\n          <code></code>\n        </div>\n        <div
+        class=\"footer__col footer__col--right\">\n          <code></code>\n        </div>\n
+        \     </div>\n    </footer>\n\n    <script src=\"/static/bundles/objects-js.e2e1136a87f5.js\"
+        type=\"text/javascript\"></script>\n    \n  </body>\n</html>\n"
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '2733'
+      Content-Type:
+      - text/html; charset=utf-8
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:09 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 404
+      message: Not Found
+version: 1
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_available_attributes.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_available_attributes.yaml
index c0f7920960..ea1ed3ae86 100644
--- a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_available_attributes.yaml
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_available_attributes.yaml
@@ -32,7 +32,7 @@ interactions:
       Cross-Origin-Opener-Policy:
       - same-origin
       Date:
-      - Mon, 16 Sep 2024 14:22:04 GMT
+      - Wed, 02 Oct 2024 11:26:10 GMT
       Referrer-Policy:
       - same-origin
       Server:
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes.yaml
index d20ef5fa22..1f0e4344c5 100644
--- a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes.yaml
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes.yaml
@@ -16,7 +16,8 @@ interactions:
     uri: http://localhost:8001/api/v2/objecttypes
   response:
     body:
-      string: '{"count":6,"next":null,"previous":null,"results":[{"url":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","uuid":"8faed0fa-7864-4409-aa6d-533a37616a9e","name":"Accepts
+      string: '{"count":7,"next":null,"previous":null,"results":[{"url":"http://objecttypes-web:8000/api/v2/objecttypes/ac1fa3f8-fb2a-4fcb-b715-d480aceeda10","uuid":"ac1fa3f8-fb2a-4fcb-b715-d480aceeda10","name":"Person
+        (published)","namePlural":"Person (published)","description":"","dataClassification":"open","maintainerOrganization":"","maintainerDepartment":"","contactPerson":"","contactEmail":"","source":"","updateFrequency":"unknown","providerOrganization":"","documentationUrl":"","labels":{},"createdAt":"2024-07-26","modifiedAt":"2024-07-26","allowGeometry":true,"versions":["http://objecttypes-web:8000/api/v2/objecttypes/ac1fa3f8-fb2a-4fcb-b715-d480aceeda10/versions/1"]},{"url":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","uuid":"8faed0fa-7864-4409-aa6d-533a37616a9e","name":"Accepts
         everything","namePlural":"Accepts everything","description":"","dataClassification":"open","maintainerOrganization":"","maintainerDepartment":"","contactPerson":"","contactEmail":"","source":"","updateFrequency":"unknown","providerOrganization":"","documentationUrl":"","labels":{},"createdAt":"2024-07-22","modifiedAt":"2024-07-22","allowGeometry":true,"versions":["http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e/versions/1"]},{"url":"http://objecttypes-web:8000/api/v2/objecttypes/644ab597-e88c-43c0-8321-f12113510b0e","uuid":"644ab597-e88c-43c0-8321-f12113510b0e","name":"Fieldset
         component","namePlural":"Fieldset component","description":"","dataClassification":"confidential","maintainerOrganization":"","maintainerDepartment":"","contactPerson":"","contactEmail":"","source":"","updateFrequency":"unknown","providerOrganization":"","documentationUrl":"","labels":{},"createdAt":"2024-02-08","modifiedAt":"2024-02-08","allowGeometry":true,"versions":["http://objecttypes-web:8000/api/v2/objecttypes/644ab597-e88c-43c0-8321-f12113510b0e/versions/1"]},{"url":"http://objecttypes-web:8000/api/v2/objecttypes/f1dde4fe-b7f9-46dc-84ae-429ae49e3705","uuid":"f1dde4fe-b7f9-46dc-84ae-429ae49e3705","name":"Geo
         in data","namePlural":"Geo in data","description":"","dataClassification":"confidential","maintainerOrganization":"","maintainerDepartment":"","contactPerson":"","contactEmail":"","source":"","updateFrequency":"unknown","providerOrganization":"","documentationUrl":"","labels":{},"createdAt":"2024-02-08","modifiedAt":"2024-02-08","allowGeometry":true,"versions":["http://objecttypes-web:8000/api/v2/objecttypes/f1dde4fe-b7f9-46dc-84ae-429ae49e3705/versions/1"]},{"url":"http://objecttypes-web:8000/api/v2/objecttypes/527b8408-7421-4808-a744-43ccb7bdaaa2","uuid":"527b8408-7421-4808-a744-43ccb7bdaaa2","name":"File
@@ -27,13 +28,13 @@ interactions:
       Connection:
       - keep-alive
       Content-Length:
-      - '3921'
+      - '4541'
       Content-Type:
       - application/json
       Cross-Origin-Opener-Policy:
       - same-origin
       Date:
-      - Mon, 16 Sep 2024 14:22:04 GMT
+      - Wed, 02 Oct 2024 11:26:10 GMT
       Referrer-Policy:
       - same-origin
       Server:
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes_versions.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes_versions.yaml
index 6f571e1507..4cc0b8b675 100644
--- a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes_versions.yaml
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginEndpointTests/ObjectsAPIPrefillPluginEndpointTests.test_list_objecttypes_versions.yaml
@@ -35,7 +35,7 @@ interactions:
       Cross-Origin-Opener-Policy:
       - same-origin
       Date:
-      - Mon, 16 Sep 2024 14:22:04 GMT
+      - Wed, 02 Oct 2024 11:26:10 GMT
       Referrer-Policy:
       - same-origin
       Server:
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_happy_flow.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_happy_flow.yaml
new file mode 100644
index 0000000000..71d48b4610
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_happy_flow.yaml
@@ -0,0 +1,108 @@
+interactions:
+- request:
+    body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+      "record": {"typeVersion": 3, "data": {"name": {"last.name": "My last name"},
+      "age": 45}, "startAt": "2024-10-02"}}'
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '210'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.32.2
+    method: POST
+    uri: http://localhost:8002/api/v2/objects
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/888f84cc-867c-48c4-815e-c3274d3ecc5c","uuid":"888f84cc-867c-48c4-815e-c3274d3ecc5c","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":1,"typeVersion":3,"data":{"name":{"last.name":"My
+        last name"},"age":45},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, POST, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '437'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:10 GMT
+      Location:
+      - http://localhost:8002/api/v2/objects/888f84cc-867c-48c4-815e-c3274d3ecc5c
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 201
+      message: Created
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      User-Agent:
+      - python-requests/2.32.2
+    method: GET
+    uri: http://localhost:8002/api/v2/objects/888f84cc-867c-48c4-815e-c3274d3ecc5c
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/888f84cc-867c-48c4-815e-c3274d3ecc5c","uuid":"888f84cc-867c-48c4-815e-c3274d3ecc5c","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":1,"typeVersion":3,"data":{"age":45,"name":{"last.name":"My
+        last name"}},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, PUT, PATCH, DELETE, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '437'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:10 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_not_found.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_not_found.yaml
new file mode 100644
index 0000000000..7ccdcbd721
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_not_found.yaml
@@ -0,0 +1,50 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      User-Agent:
+      - python-requests/2.32.2
+    method: GET
+    uri: http://localhost:8002/api/v2/objects/048a37ca-a602-4158-9e60-9f06f3e47e2a
+  response:
+    body:
+      string: '{"detail":"Not found."}'
+    headers:
+      Allow:
+      - GET, PUT, PATCH, DELETE, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '23'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 404
+      message: Not Found
+version: 1
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_returns_empty_values.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_returns_empty_values.yaml
new file mode 100644
index 0000000000..11a70a4bfd
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefill_values_when_reference_returns_empty_values.yaml
@@ -0,0 +1,105 @@
+interactions:
+- request:
+    body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+      "record": {"typeVersion": 3, "data": {}, "startAt": "2024-10-02"}}'
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '162'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.32.2
+    method: POST
+    uri: http://localhost:8002/api/v2/objects
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/fa95e317-9d82-4bcb-b9c7-e0d63460c29f","uuid":"fa95e317-9d82-4bcb-b9c7-e0d63460c29f","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":1,"typeVersion":3,"data":{},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, POST, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '393'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Location:
+      - http://localhost:8002/api/v2/objects/fa95e317-9d82-4bcb-b9c7-e0d63460c29f
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 201
+      message: Created
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      User-Agent:
+      - python-requests/2.32.2
+    method: GET
+    uri: http://localhost:8002/api/v2/objects/fa95e317-9d82-4bcb-b9c7-e0d63460c29f
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/fa95e317-9d82-4bcb-b9c7-e0d63460c29f","uuid":"fa95e317-9d82-4bcb-b9c7-e0d63460c29f","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":1,"typeVersion":3,"data":{},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, PUT, PATCH, DELETE, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '393'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefilled_values_are_updated_in_the_object.yaml b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefilled_values_are_updated_in_the_object.yaml
new file mode 100644
index 0000000000..b83b4504ed
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/files/vcr_cassettes/ObjectsAPIPrefillPluginTests/ObjectsAPIPrefillPluginTests.test_prefilled_values_are_updated_in_the_object.yaml
@@ -0,0 +1,206 @@
+interactions:
+- request:
+    body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+      "record": {"typeVersion": 3, "data": {"name": {"last.name": "My last name"},
+      "age": 45}, "startAt": "2024-10-02"}}'
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '210'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.32.2
+    method: POST
+    uri: http://localhost:8002/api/v2/objects
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/c1ef1a9c-d19b-4cc2-8b58-800ba00317f0","uuid":"c1ef1a9c-d19b-4cc2-8b58-800ba00317f0","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":1,"typeVersion":3,"data":{"name":{"last.name":"My
+        last name"},"age":45},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, POST, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '437'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Location:
+      - http://localhost:8002/api/v2/objects/c1ef1a9c-d19b-4cc2-8b58-800ba00317f0
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 201
+      message: Created
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      User-Agent:
+      - python-requests/2.32.2
+    method: GET
+    uri: http://localhost:8002/api/v2/objects/c1ef1a9c-d19b-4cc2-8b58-800ba00317f0
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/c1ef1a9c-d19b-4cc2-8b58-800ba00317f0","uuid":"c1ef1a9c-d19b-4cc2-8b58-800ba00317f0","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":1,"typeVersion":3,"data":{"age":45,"name":{"last.name":"My
+        last name"}},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, PUT, PATCH, DELETE, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '437'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 171be5abaf41e7856b423ad513df1ef8f867ff48
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.32.2
+    method: GET
+    uri: http://localhost:8001/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48
+  response:
+    body:
+      string: '{"url":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","uuid":"8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","name":"Person","namePlural":"Persons","description":"","dataClassification":"open","maintainerOrganization":"","maintainerDepartment":"","contactPerson":"","contactEmail":"","source":"","updateFrequency":"unknown","providerOrganization":"","documentationUrl":"","labels":{},"createdAt":"2023-10-24","modifiedAt":"2024-02-08","allowGeometry":true,"versions":["http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48/versions/1","http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48/versions/2","http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48/versions/3"]}'
+    headers:
+      Allow:
+      - GET, PUT, PATCH, DELETE, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '790'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: '{"record": {"typeVersion": 3, "data": {"age": 40, "name": {"last.name":
+      "My updated last name"}}, "startAt": "2024-10-02"}}'
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate, br
+      Authorization:
+      - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '123'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.32.2
+    method: PATCH
+    uri: http://localhost:8002/api/v2/objects/c1ef1a9c-d19b-4cc2-8b58-800ba00317f0
+  response:
+    body:
+      string: '{"url":"http://objects-web:8000/api/v2/objects/c1ef1a9c-d19b-4cc2-8b58-800ba00317f0","uuid":"c1ef1a9c-d19b-4cc2-8b58-800ba00317f0","type":"http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48","record":{"index":2,"typeVersion":3,"data":{"age":40,"name":{"last.name":"My
+        updated last name"}},"geometry":null,"startAt":"2024-10-02","endAt":null,"registrationAt":"2024-10-02","correctionFor":null,"correctedBy":null}}'
+    headers:
+      Allow:
+      - GET, PUT, PATCH, DELETE, HEAD, OPTIONS
+      Connection:
+      - keep-alive
+      Content-Crs:
+      - EPSG:4326
+      Content-Length:
+      - '445'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Wed, 02 Oct 2024 11:26:11 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - nginx/1.27.0
+      Vary:
+      - origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_config.py b/src/openforms/prefill/contrib/objects_api/tests/test_config.py
new file mode 100644
index 0000000000..07708e0389
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/test_config.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+from unittest.mock import patch
+
+from django.test import override_settings
+from django.utils.translation import gettext as _
+
+from rest_framework.test import APITestCase
+from zgw_consumers.constants import APITypes, AuthTypes
+from zgw_consumers.test.factories import ServiceFactory
+
+from openforms.contrib.objects_api.tests.factories import ObjectsAPIGroupConfigFactory
+from openforms.plugins.exceptions import InvalidPluginConfiguration
+from openforms.registrations.contrib.objects_api.models import ObjectsAPIConfig
+from openforms.utils.tests.vcr import OFVCRMixin
+
+from ....registry import register
+
+plugin = register["objects_api"]
+
+VCR_TEST_FILES = Path(__file__).parent / "files"
+
+
+class ObjectsAPIPrefillPluginConfigTests(OFVCRMixin, APITestCase):
+    """This test case requires the Objects & Objecttypes API to be running.
+    See the relevant Docker compose in the ``docker/`` folder.
+    """
+
+    VCR_TEST_FILES = VCR_TEST_FILES
+
+    def setUp(self):
+        super().setUp()
+
+        config_patcher = patch(
+            "openforms.registrations.contrib.objects_api.models.ObjectsAPIConfig.get_solo",
+            return_value=ObjectsAPIConfig(),
+        )
+        self.mock_get_config = config_patcher.start()
+        self.addCleanup(config_patcher.stop)
+
+        self.objects_api_group = ObjectsAPIGroupConfigFactory.create(
+            for_test_docker_compose=True
+        )
+
+    @override_settings(LANGUAGE_CODE="en")
+    def test_undefined_service_raises_exception(self):
+        self.objects_api_group.objects_service = None
+        self.objects_api_group.save()
+
+        with self.assertRaisesMessage(
+            InvalidPluginConfiguration,
+            _(
+                "Objects API endpoint is not configured for Objects API group {objects_api_group}."
+            ).format(objects_api_group=self.objects_api_group),
+        ):
+            plugin.check_config()
+
+    def test_invalid_service_raises_exception(self):
+        objects_service = ServiceFactory.create(
+            api_root="http://localhost:8002/api/v2/invalid",
+            api_type=APITypes.orc,
+            oas="https://example.com/",
+            header_key="Authorization",
+            header_value="Token INVALID",
+            auth_type=AuthTypes.api_key,
+        )
+        self.objects_api_group.objects_service = objects_service
+        self.objects_api_group.save()
+
+        with self.assertRaises(
+            InvalidPluginConfiguration,
+        ) as exc:
+            plugin.check_config()
+
+        self.assertIn("404 Client Error", exc.exception.args[0])
diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_endpoints.py b/src/openforms/prefill/contrib/objects_api/tests/test_endpoints.py
index 0e90728a30..e4c420b529 100644
--- a/src/openforms/prefill/contrib/objects_api/tests/test_endpoints.py
+++ b/src/openforms/prefill/contrib/objects_api/tests/test_endpoints.py
@@ -45,17 +45,8 @@ def setUp(self):
             "openforms.registrations.contrib.objects_api.models.ObjectsAPIConfig.get_solo",
             return_value=ObjectsAPIConfig(),
         )
-        attributes_patcher = patch(
-            "openforms.prefill.contrib.objects_api.plugin.ObjectsAPIPrefill.get_available_attributes",
-            return_value=[
-                ("value one", "value one (string)"),
-                ("another value", "another value (string)"),
-            ],
-        )
         self.mock_get_config = config_patcher.start()
-        self.mock_attributes = attributes_patcher.start()
         self.addCleanup(config_patcher.stop)
-        self.addCleanup(attributes_patcher.stop)
 
         self.objects_api_group = ObjectsAPIGroupConfigFactory.create(
             for_test_docker_compose=True
diff --git a/src/openforms/prefill/contrib/objects_api/tests/test_prefill.py b/src/openforms/prefill/contrib/objects_api/tests/test_prefill.py
new file mode 100644
index 0000000000..829b9dfc4f
--- /dev/null
+++ b/src/openforms/prefill/contrib/objects_api/tests/test_prefill.py
@@ -0,0 +1,270 @@
+from pathlib import Path
+from unittest.mock import patch
+from uuid import UUID
+
+from rest_framework.test import APITestCase
+
+from openforms.contrib.objects_api.clients import get_objects_client
+from openforms.contrib.objects_api.helpers import prepare_data_for_registration
+from openforms.contrib.objects_api.tests.factories import ObjectsAPIGroupConfigFactory
+from openforms.forms.tests.factories import FormVariableFactory
+from openforms.logging.models import TimelineLogProxy
+from openforms.registrations.contrib.objects_api.models import ObjectsAPIConfig
+from openforms.registrations.contrib.objects_api.plugin import ObjectsAPIRegistration
+from openforms.registrations.contrib.objects_api.typing import RegistrationOptionsV2
+from openforms.submissions.tests.factories import SubmissionFactory
+from openforms.utils.tests.vcr import OFVCRMixin
+
+from ....service import prefill_variables
+
+VCR_TEST_FILES = Path(__file__).parent / "files"
+
+
+class ObjectsAPIPrefillPluginTests(OFVCRMixin, APITestCase):
+    """This test case requires the Objects & Objecttypes API to be running.
+    See the relevant Docker compose in the ``docker/`` folder.
+    """
+
+    VCR_TEST_FILES = VCR_TEST_FILES
+
+    def setUp(self):
+        super().setUp()
+
+        config_patcher = patch(
+            "openforms.registrations.contrib.objects_api.models.ObjectsAPIConfig.get_solo",
+            return_value=ObjectsAPIConfig(),
+        )
+        self.mock_get_config = config_patcher.start()
+        self.addCleanup(config_patcher.stop)
+
+        self.objects_api_group = ObjectsAPIGroupConfigFactory.create(
+            for_test_docker_compose=True
+        )
+
+    def test_prefill_values_happy_flow(self):
+        # We manually create the objects instance as if it was created upfront by some external party
+        with get_objects_client(self.objects_api_group) as client:
+            created_obj = client.create_object(
+                record_data=prepare_data_for_registration(
+                    data={
+                        "name": {"last.name": "My last name"},
+                        "age": 45,
+                    },
+                    objecttype_version=3,
+                ),
+                objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+            )
+
+        submission = SubmissionFactory.from_components(
+            initial_data_reference=created_obj["uuid"],
+            components_list=[
+                {
+                    "type": "textfield",
+                    "key": "age",
+                    "label": "Age",
+                },
+                {
+                    "type": "textfield",
+                    "key": "lastName",
+                    "label": "Last name",
+                },
+            ],
+        )
+        FormVariableFactory.create(
+            form=submission.form,
+            prefill_plugin="objects_api",
+            prefill_options={
+                "objects_api_group": self.objects_api_group.pk,
+                "objecttype_uuid": "8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+                "objecttype_version": 3,
+                "variables_mapping": [
+                    {"variable_key": "lastName", "target_path": ["name", "last.name"]},
+                    {"variable_key": "age", "target_path": ["age"]},
+                ],
+            },
+        )
+
+        prefill_variables(submission=submission)
+        state = submission.load_submission_value_variables_state()
+
+        self.assertEqual(TimelineLogProxy.objects.count(), 1)
+        logs = TimelineLogProxy.objects.get()
+
+        self.assertEqual(logs.extra_data["log_event"], "prefill_retrieve_success")
+        self.assertEqual(logs.extra_data["plugin_id"], "objects_api")
+        self.assertEqual(state.variables["lastName"].value, "My last name")
+        self.assertEqual(state.variables["age"].value, 45)
+
+    def test_prefill_values_when_reference_not_found(self):
+        submission = SubmissionFactory.from_components(
+            initial_data_reference="048a37ca-a602-4158-9e60-9f06f3e47e2a",
+            components_list=[
+                {
+                    "type": "textfield",
+                    "key": "age",
+                    "label": "Age",
+                },
+                {
+                    "type": "textfield",
+                    "key": "lastName",
+                    "label": "Last name",
+                },
+            ],
+        )
+        FormVariableFactory.create(
+            form=submission.form,
+            prefill_plugin="objects_api",
+            prefill_options={
+                "objects_api_group": self.objects_api_group.pk,
+                "objecttype_uuid": "8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+                "objecttype_version": 3,
+                "variables_mapping": [
+                    {"variable_key": "lastName", "target_path": ["name", "last.name"]},
+                    {"variable_key": "age", "target_path": ["age"]},
+                ],
+            },
+        )
+
+        prefill_variables(submission=submission)
+        state = submission.load_submission_value_variables_state()
+
+        self.assertEqual(TimelineLogProxy.objects.count(), 1)
+        logs = TimelineLogProxy.objects.get()
+
+        self.assertEqual(logs.extra_data["log_event"], "prefill_retrieve_failure")
+        self.assertEqual(logs.extra_data["plugin_id"], "objects_api")
+        self.assertIsNone(state.variables["lastName"].value)
+        self.assertIsNone(state.variables["age"].value)
+
+    def test_prefill_values_when_reference_returns_empty_values(self):
+        # We manually create the objects instance as if it was created upfront by some external party
+        with get_objects_client(self.objects_api_group) as client:
+            created_obj = client.create_object(
+                record_data=prepare_data_for_registration(
+                    data={},
+                    objecttype_version=3,
+                ),
+                objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+            )
+
+        submission = SubmissionFactory.from_components(
+            initial_data_reference=created_obj["uuid"],
+            components_list=[
+                {
+                    "type": "textfield",
+                    "key": "age",
+                    "label": "Age",
+                },
+                {
+                    "type": "textfield",
+                    "key": "lastName",
+                    "label": "Last name",
+                },
+            ],
+        )
+        FormVariableFactory.create(
+            form=submission.form,
+            prefill_plugin="objects_api",
+            prefill_options={
+                "objects_api_group": self.objects_api_group.pk,
+                "objecttype_uuid": "8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+                "objecttype_version": 3,
+                "variables_mapping": [
+                    {"variable_key": "lastName", "target_path": ["name", "last.name"]},
+                    {"variable_key": "age", "target_path": ["age"]},
+                ],
+            },
+        )
+
+        prefill_variables(submission=submission)
+        state = submission.load_submission_value_variables_state()
+
+        self.assertEqual(TimelineLogProxy.objects.count(), 1)
+        logs = TimelineLogProxy.objects.get()
+
+        self.assertEqual(logs.extra_data["log_event"], "prefill_retrieve_empty")
+        self.assertEqual(logs.extra_data["plugin_id"], "objects_api")
+        self.assertIsNone(state.variables["lastName"].value)
+        self.assertIsNone(state.variables["age"].value)
+
+    def test_prefilled_values_are_updated_in_the_object(self):
+        """
+        This tests that a created object in the ObjectsAPI prefills the form variables (components) as
+        expected and then (in the same submission) we make sure that we can update the object.
+        """
+        # We manually create the objects instance as if it was created upfront by some external party
+        with get_objects_client(self.objects_api_group) as client:
+            created_obj = client.create_object(
+                record_data=prepare_data_for_registration(
+                    data={
+                        "name": {"last.name": "My last name"},
+                        "age": 45,
+                    },
+                    objecttype_version=3,
+                ),
+                objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+            )
+
+        submission = SubmissionFactory.from_components(
+            initial_data_reference=created_obj["uuid"],
+            components_list=[
+                {
+                    "type": "textfield",
+                    "key": "age",
+                    "label": "Age",
+                },
+                {
+                    "type": "textfield",
+                    "key": "lastName",
+                    "label": "Last name",
+                },
+            ],
+            submitted_data={
+                "age": 40,
+                "lastName": "My updated last name",
+            },
+            form__registration_backend="objects_api",
+            with_report=False,
+        )
+        FormVariableFactory.create(
+            form=submission.form,
+            prefill_plugin="objects_api",
+            prefill_options={
+                "objects_api_group": self.objects_api_group.pk,
+                "objecttype_uuid": "8e46e0a5-b1b4-449b-b9e9-fa3cea655f48",
+                "objecttype_version": 3,
+                "variables_mapping": [
+                    {"variable_key": "lastName", "target_path": ["name", "last.name"]},
+                    {"variable_key": "age", "target_path": ["age"]},
+                ],
+            },
+        )
+
+        prefill_variables(submission=submission)
+
+        v2_options: RegistrationOptionsV2 = {
+            "version": 2,
+            "objects_api_group": self.objects_api_group,
+            # See the docker compose fixtures for more info on these values:
+            "objecttype": UUID("8e46e0a5-b1b4-449b-b9e9-fa3cea655f48"),
+            "objecttype_version": 3,
+            "variables_mapping": [
+                {
+                    "variable_key": "age",
+                    "target_path": ["age"],
+                },
+                {"variable_key": "lastName", "target_path": ["name", "last.name"]},
+            ],
+            "update_existing_object": True,
+        }
+
+        plugin = ObjectsAPIRegistration("objects_api")
+        result = plugin.register_submission(submission, v2_options)
+
+        assert result is not None
+
+        self.assertTrue(result["uuid"], created_obj["uuid"])
+        self.assertEqual(result["record"]["data"]["age"], 40)
+        self.assertEqual(
+            result["record"]["data"]["name"]["last.name"], "My updated last name"
+        )
diff --git a/src/openforms/prefill/service.py b/src/openforms/prefill/service.py
new file mode 100644
index 0000000000..d0f334bcd4
--- /dev/null
+++ b/src/openforms/prefill/service.py
@@ -0,0 +1,172 @@
+"""
+This package holds the base module structure for the pre-fill plugins used in Open Forms.
+
+Various sources exist that can be consulted to fetch data for an active session,
+where the BSN, CoC number... can be used to retrieve this data. Think of pre-filling
+the address details of a person after logging in with DigiD.
+
+The package integrates with the form builder such that it's possible for every form
+field to a) select which pre-fill plugin to use and which value to use from the fetched
+result and b) define a user-defined variable in which the ``prefill_options`` are configured.
+Plugins can be registered using a similar approach to the registrations
+package. Each plugin is responsible for exposing which attributes/data fragments are
+available, and for performing the actual look-up. Plugins receive the
+:class:`openforms.submissions.models.Submission` instance that represents the current
+form session of an end-user.
+
+Prefill values are embedded as default values for form fields, dynamically for every
+user session using the component rewrite functionality in the serializers.
+
+So, to recap:
+
+1. Plugins are defined and registered
+2. When editing form definitions in the admin, content editors can opt-in to pre-fill
+   functionality. They select the desired plugin, and then the desired attribute from
+   that plugin.
+3. Content editors can also define a user-defined variable and configure the plugin and
+   the necessary options by selecting the desired choices for the ``prefill_options``.
+4. End-user starts the form and logs in, thereby creating a session/``Submission``
+5. The submission-specific form definition configuration is enhanced with the pre-filled
+   form field default values.
+"""
+
+import logging
+from collections import defaultdict
+
+import elasticapm
+from glom import Path, PathAccessError, glom
+
+from openforms.formio.service import FormioConfigurationWrapper
+from openforms.forms.models import FormVariable
+from openforms.submissions.models import Submission
+from openforms.variables.constants import FormVariableSources
+
+from .registry import Registry
+from .sources.component import (
+    fetch_prefill_values as fetch_prefill_values_for_component,
+)
+from .sources.user_defined import (
+    fetch_prefill_values as fetch_prefill_values_for_user_defined,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def inject_prefill(
+    configuration_wrapper: FormioConfigurationWrapper, submission: Submission
+) -> None:
+    """
+    Mutates each component found in configuration according to the prefilled values.
+
+    :param configuration_wrapper: The Formiojs JSON schema wrapper describing an entire
+    form or an individual component within the form.
+    :param submission: The :class:`openforms.submissions.models.Submission` instance
+    that holds the values of the prefill data. The prefill data was fetched earlier,
+    see :func:`prefill_variables`.
+
+    The prefill values are looped over by key: value, and for each value the matching
+    component is looked up to normalize it in the context of the component.
+    """
+    from openforms.formio.service import normalize_value_for_component
+
+    prefilled_data = submission.get_prefilled_data()
+    for key, prefill_value in prefilled_data.items():
+        try:
+            component = configuration_wrapper[key]
+        except KeyError:
+            # The component to prefill is not in this step
+            continue
+
+        if not (prefill := component.get("prefill")):
+            continue
+        if not prefill.get("plugin"):
+            continue
+        if not prefill.get("attribute"):
+            continue
+
+        default_value = component.get("defaultValue")
+        # 1693: we need to normalize values according to the format expected by the
+        # component. For example, (some) prefill plugins return postal codes without
+        # space between the digits and the letters.
+        prefill_value = normalize_value_for_component(component, prefill_value)
+
+        if prefill_value != default_value and default_value is not None:
+            logger.info(
+                "Overwriting non-null default value for component %r",
+                component,
+            )
+        component["defaultValue"] = prefill_value
+
+
+@elasticapm.capture_span(span_type="app.prefill")
+def prefill_variables(submission: Submission, register: Registry | None = None) -> None:
+    """Update the submission variables state with the fetched attribute values.
+
+    For each submission value variable that need to be prefilled, the according plugin will
+    be used to fetch the value. If ``register`` is not specified, the default registry instance
+    will be used.
+    """
+    from openforms.formio.service import normalize_value_for_component
+
+    from .registry import register as default_register
+
+    register = register or default_register
+
+    state = submission.load_submission_value_variables_state()
+    variables_to_prefill = state.get_prefill_variables()
+
+    component_variables: list[FormVariable] = []
+    user_defined_variables: list[FormVariable] = []
+    for variable in variables_to_prefill:
+        assert variable.form_variable is not None
+
+        if (
+            variable.form_variable.source == FormVariableSources.component
+            and variable.form_variable.prefill_attribute
+        ):
+            component_variables.append(variable.form_variable)
+        elif (
+            variable.form_variable.source == FormVariableSources.user_defined
+            and variable.form_variable.prefill_options
+        ):
+            user_defined_variables.append(variable.form_variable)
+
+    total_config_wrapper = submission.total_configuration_wrapper
+    prefill_data: defaultdict[str, (dict[str, str] | str)] = defaultdict(dict)
+
+    # Component source prefill
+    if component_variables:
+        if results := fetch_prefill_values_for_component(
+            submission, register, component_variables
+        ):
+
+            for form_variable in component_variables:
+                try:
+                    prefill_value = glom(
+                        results,
+                        Path(
+                            form_variable.prefill_plugin,
+                            form_variable.prefill_identifier_role,
+                            form_variable.prefill_attribute,
+                        ),
+                    )
+                except PathAccessError:
+                    continue
+                else:
+                    component = total_config_wrapper[form_variable.key]
+                    prefill_value = normalize_value_for_component(
+                        component, prefill_value
+                    )
+                    prefill_data[form_variable.key] = prefill_value
+
+    # User defined source prefill
+    if user_defined_variables:
+        if results := fetch_prefill_values_for_user_defined(
+            submission, register, user_defined_variables
+        ):
+            for form_variable in user_defined_variables:
+                for mapping in form_variable.prefill_options["variables_mapping"]:
+                    prefill_value = results[mapping["variable_key"]]
+                    prefill_data[mapping["variable_key"]] = prefill_value
+
+    state.save_prefill_data(prefill_data)
diff --git a/src/openforms/prefill/sources/__init__.py b/src/openforms/prefill/sources/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/openforms/prefill/sources/component.py b/src/openforms/prefill/sources/component.py
new file mode 100644
index 0000000000..a6cd5f9e3e
--- /dev/null
+++ b/src/openforms/prefill/sources/component.py
@@ -0,0 +1,78 @@
+import logging
+from collections import defaultdict
+from typing import Any
+
+import elasticapm
+from glom import Path, assign
+from zgw_consumers.concurrent import parallel
+
+from openforms.forms.models import FormVariable
+from openforms.plugins.exceptions import PluginNotEnabled
+from openforms.submissions.models import Submission
+
+from ..registry import Registry
+
+logger = logging.getLogger(__name__)
+
+
+def fetch_prefill_values(
+    submission: Submission, register: Registry, form_variables: list[FormVariable]
+) -> dict[str, dict[str, Any]]:
+    # local import to prevent AppRegistryNotReady:
+    from openforms.logging import logevent
+
+    # grouped_fields is a dict of the following shape:
+    # {"plugin_id": {"identifier_role": ["attr_1", "attr_2"]}}
+    # "identifier_role" is either "main" or "authorizee"
+    grouped_fields: defaultdict[str, defaultdict[str, list[str]]] = defaultdict(
+        lambda: defaultdict(list)
+    )
+
+    for form_variable in form_variables:
+        plugin_id = form_variable.prefill_plugin
+        identifier_role = form_variable.prefill_identifier_role
+        attribute_name = form_variable.prefill_attribute
+
+        grouped_fields[plugin_id][identifier_role].append(attribute_name)
+
+    @elasticapm.capture_span(span_type="app.prefill")
+    def invoke_plugin(
+        item: tuple[str, str, list[str]]
+    ) -> tuple[str, str, dict[str, Any]]:
+        plugin_id, identifier_role, fields = item
+        plugin = register[plugin_id]
+        if not plugin.is_enabled:
+            raise PluginNotEnabled()
+
+        try:
+            values = plugin.get_prefill_values(submission, fields, identifier_role)
+        except Exception as e:
+            logger.exception(f"exception in prefill plugin '{plugin_id}'")
+            logevent.prefill_retrieve_failure(submission, plugin, e)
+            values = {}
+        else:
+            if values:
+                logevent.prefill_retrieve_success(submission, plugin, fields)
+            else:
+                logevent.prefill_retrieve_empty(submission, plugin, fields)
+
+        return plugin_id, identifier_role, values
+
+    invoke_plugin_args = []
+    for plugin_id, field_groups in grouped_fields.items():
+        for identifier_role, fields in field_groups.items():
+            invoke_plugin_args.append((plugin_id, identifier_role, fields))
+
+    with parallel() as executor:
+        results = executor.map(invoke_plugin, invoke_plugin_args)
+
+    collected_results = {}
+    for plugin_id, identifier_role, values_dict in list(results):
+        assign(
+            collected_results,
+            Path(plugin_id, identifier_role),
+            values_dict,
+            missing=dict,
+        )
+
+    return collected_results
diff --git a/src/openforms/prefill/sources/user_defined.py b/src/openforms/prefill/sources/user_defined.py
new file mode 100644
index 0000000000..3b97cb22ce
--- /dev/null
+++ b/src/openforms/prefill/sources/user_defined.py
@@ -0,0 +1,36 @@
+import logging
+from typing import Any
+
+from openforms.forms.models import FormVariable
+from openforms.submissions.models import Submission
+
+from ..registry import Registry
+
+logger = logging.getLogger(__name__)
+
+
+def fetch_prefill_values(
+    submission: Submission,
+    register: Registry,
+    form_variables: list[FormVariable],
+) -> dict[str, (dict[str, Any] | dict[str, str])]:
+    # local import to prevent AppRegistryNotReady:
+    from openforms.logging import logevent
+
+    values = {}
+    for form_var in form_variables:
+        plugin = register[form_var.prefill_plugin]
+
+        try:
+            values = plugin.get_prefill_values_from_mappings(submission, form_var)
+        except Exception as e:
+            logger.exception(f"exception in prefill plugin '{plugin.identifier}'")
+            logevent.prefill_retrieve_failure(submission, plugin, e)
+            values = {}
+        else:
+            if values:
+                logevent.prefill_retrieve_success(submission, plugin, values)
+            else:
+                logevent.prefill_retrieve_empty(submission, plugin, values)
+
+    return values
diff --git a/src/openforms/prefill/tests/test_prefill_hook.py b/src/openforms/prefill/tests/test_prefill_hook.py
index b63968c8c8..d0302bd2dd 100644
--- a/src/openforms/prefill/tests/test_prefill_hook.py
+++ b/src/openforms/prefill/tests/test_prefill_hook.py
@@ -16,11 +16,11 @@
 from openforms.submissions.models import Submission, SubmissionValueVariable
 from openforms.submissions.tests.factories import SubmissionFactory
 
-from .. import inject_prefill, prefill_variables
 from ..base import BasePlugin
 from ..constants import IdentifierRoles
 from ..contrib.demo.plugin import DemoPrefill
 from ..registry import Registry, register as prefill_register
+from ..service import inject_prefill, prefill_variables
 
 register = Registry()
 
diff --git a/src/openforms/prefill/tests/test_prefill_variables.py b/src/openforms/prefill/tests/test_prefill_variables.py
index eab98e54b1..747929b85c 100644
--- a/src/openforms/prefill/tests/test_prefill_variables.py
+++ b/src/openforms/prefill/tests/test_prefill_variables.py
@@ -20,7 +20,7 @@
     SubmissionStepFactory,
 )
 
-from .. import prefill_variables
+from ..service import prefill_variables
 
 CONFIGURATION = {
     "display": "form",
@@ -50,8 +50,9 @@
 
 
 class PrefillVariablesTests(TestCase):
+
     @patch(
-        "openforms.prefill._fetch_prefill_values",
+        "openforms.prefill.service.fetch_prefill_values_for_component",
         return_value={
             "demo": {
                 "main": {"random_string": "Not so random string", "random_number": 123}
@@ -87,7 +88,7 @@ def test_applying_prefill_plugins(self, m_prefill):
         )
 
     @patch(
-        "openforms.prefill._fetch_prefill_values",
+        "openforms.prefill.service.fetch_prefill_values_for_component",
         return_value={
             "postcode": {"main": {"static": "1015CJ"}},
             "birthDate": {"main": {"static": "19990615"}},
diff --git a/src/openforms/prefill/utils.py b/src/openforms/prefill/utils.py
new file mode 100644
index 0000000000..3c20251f47
--- /dev/null
+++ b/src/openforms/prefill/utils.py
@@ -0,0 +1,18 @@
+from typing import Any
+
+from glom import Path, PathAccessError, glom
+
+
+def find_in_dicts(*dicts: dict[str, Any], path: Path) -> str | None:
+    """
+    Given a specific path, look up the value in the specified sequence of dictionaries.
+
+    :param dicts: a sequence of dictionaries to look up in.
+    :param path: an :class:`Path` instance which contains the segments of the path.
+    :return: an str (the found value) or None.
+    """
+    for data in dicts:
+        try:
+            return glom(data, path)
+        except PathAccessError:
+            continue
diff --git a/src/openforms/submissions/api/viewsets.py b/src/openforms/submissions/api/viewsets.py
index b40acd9cfc..ebe974b574 100644
--- a/src/openforms/submissions/api/viewsets.py
+++ b/src/openforms/submissions/api/viewsets.py
@@ -24,7 +24,7 @@
 from openforms.formio.service import FormioData
 from openforms.forms.models import FormStep
 from openforms.logging import logevent
-from openforms.prefill import prefill_variables
+from openforms.prefill.service import prefill_variables
 from openforms.utils.patches.rest_framework_nested.viewsets import NestedViewSetMixin
 
 from ..attachments import attach_uploads_to_submission_step
diff --git a/src/openforms/submissions/tests/test_start_submission.py b/src/openforms/submissions/tests/test_start_submission.py
index ec46588ad2..46351e1cf6 100644
--- a/src/openforms/submissions/tests/test_start_submission.py
+++ b/src/openforms/submissions/tests/test_start_submission.py
@@ -23,11 +23,7 @@
 from rest_framework.test import APITestCase
 
 from openforms.authentication.service import FORM_AUTH_SESSION_KEY, AuthAttribute
-from openforms.forms.tests.factories import (
-    FormFactory,
-    FormStepFactory,
-    FormVariableFactory,
-)
+from openforms.forms.tests.factories import FormFactory, FormStepFactory
 
 from ..constants import SUBMISSIONS_SESSION_KEY, SubmissionValueVariableSources
 from ..models import Submission, SubmissionValueVariable
@@ -203,12 +199,20 @@ def test_start_submission_bad_form_url(self):
 
     @patch("openforms.logging.logevent._create_log")
     def test_start_submission_with_prefill(self, mock_logevent):
-        FormVariableFactory.create(
+        FormStepFactory.create(
             form=self.form,
-            form_definition=self.step.form_definition,
-            prefill_plugin="demo",
-            prefill_attribute="random_string",
+            form_definition__configuration={
+                "components": [
+                    {
+                        "type": "textfield",
+                        "key": "test-key",
+                        "label": "Test label",
+                        "prefill": {"plugin": "demo", "attribute": "random_string"},
+                    }
+                ]
+            },
         )
+
         body = {
             "form": f"http://testserver.com{self.form_url}",
             "formUrl": "http://testserver.com/my-form",
diff --git a/src/openforms/submissions/tests/test_submission_step_validate.py b/src/openforms/submissions/tests/test_submission_step_validate.py
index 7b1077d10a..d007e731ed 100644
--- a/src/openforms/submissions/tests/test_submission_step_validate.py
+++ b/src/openforms/submissions/tests/test_submission_step_validate.py
@@ -16,7 +16,7 @@
     FormStepFactory,
     FormVariableFactory,
 )
-from openforms.prefill import prefill_variables
+from openforms.prefill.service import prefill_variables
 from openforms.variables.constants import FormVariableDataTypes
 
 from ..models import SubmissionValueVariable
@@ -209,7 +209,7 @@ def test_prefilled_data_normalised(self):
 
     @tag("gh-1899")
     @patch(
-        "openforms.prefill._fetch_prefill_values",
+        "openforms.prefill.service.fetch_prefill_values_for_component",
         return_value={
             "postcode": {"main": {"static": "1015CJ"}},
         },
diff --git a/src/openforms/variables/tests/test_views.py b/src/openforms/variables/tests/test_views.py
index 3168aceb3f..368f582669 100644
--- a/src/openforms/variables/tests/test_views.py
+++ b/src/openforms/variables/tests/test_views.py
@@ -80,6 +80,7 @@ def get_initial_value(self, *args, **kwargs):
                 "prefill_plugin": "",
                 "prefill_attribute": "",
                 "prefill_identifier_role": IdentifierRoles.main,
+                "prefill_options": {},
                 "data_type": FormVariableDataTypes.datetime,
                 "data_format": "",
                 "is_sensitive_data": False,
@@ -169,6 +170,7 @@ def get_variables(self) -> list[FormVariable]:
                         "prefill_plugin": "",
                         "prefill_attribute": "",
                         "prefill_identifier_role": IdentifierRoles.main,
+                        "prefill_options": {},
                         "data_type": FormVariableDataTypes.string,
                         "data_format": "",
                         "is_sensitive_data": False,