From 8ed75847eef022f78675f60eede5014317440e6a Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 5 Jul 2026 10:23:48 +0000 Subject: [PATCH 01/16] CoreApi.PersonProfiles API v2.Configuration view and REST API v2 routes definition. --- .../CoreApi/config/match_callbacks_routes.php | 55 +++++++ .../CoreApi/config/person_profiles_routes.php | 86 ++++++++++ app/plugins/CoreApi/config/plugin.json | 21 ++- app/plugins/CoreApi/config/routes.php | 34 +--- .../resources/locales/en_US/core_api.po | 42 +++++ .../Controller/PersonProfilesController.php | 69 ++++++++ .../src/Lib/Enum/ResponseTypesEnum.php | 38 +++++ .../src/Model/Entity/PersonProfile.php | 59 +++++++ .../src/Model/Table/PersonProfilesTable.php | 152 ++++++++++++++++++ .../templates/PersonProfiles/fields.inc | 52 ++++++ 10 files changed, 574 insertions(+), 34 deletions(-) create mode 100644 app/plugins/CoreApi/config/match_callbacks_routes.php create mode 100644 app/plugins/CoreApi/config/person_profiles_routes.php create mode 100644 app/plugins/CoreApi/src/Controller/PersonProfilesController.php create mode 100644 app/plugins/CoreApi/src/Lib/Enum/ResponseTypesEnum.php create mode 100644 app/plugins/CoreApi/src/Model/Entity/PersonProfile.php create mode 100644 app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php create mode 100644 app/plugins/CoreApi/templates/PersonProfiles/fields.inc diff --git a/app/plugins/CoreApi/config/match_callbacks_routes.php b/app/plugins/CoreApi/config/match_callbacks_routes.php new file mode 100644 index 000000000..163c6a7c0 --- /dev/null +++ b/app/plugins/CoreApi/config/match_callbacks_routes.php @@ -0,0 +1,55 @@ +scope('/api/match', function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->applyMiddleware('csrf'); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + + $builder->post( + '/{coid}/v1/resolution', + ['plugin' => 'CoreApi', 'controller' => 'MatchCallbackApiV1', 'action' => 'resolveMatch'] + ) + ->setPass(['coid']) + ->setPatterns(['coid' => '[0-9]+']); +}); diff --git a/app/plugins/CoreApi/config/person_profiles_routes.php b/app/plugins/CoreApi/config/person_profiles_routes.php new file mode 100644 index 000000000..c14d3dfa0 --- /dev/null +++ b/app/plugins/CoreApi/config/person_profiles_routes.php @@ -0,0 +1,86 @@ +scope('/api/person-profiles', function (RouteBuilder $builder) { + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + + // index (list) + $builder->get( + '/{coid}/v2/person', + ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'index'] + ) + ->setPass(['coid']) + ->setPatterns(['coid' => '[0-9]+']); + + // create + $builder->post( + '/{coid}/v2/person', + ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'create'] + ) + ->setPass(['coid']) + ->setPatterns(['coid' => '[0-9]+']); + + // read + $builder->get( + '/{coid}/v2/person/{identifier}', + ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'read'] + ) + ->setPass(['coid', 'identifier']) + ->setPatterns([ + 'coid' => '[0-9]+', + 'identifier' => '[^/]+' + ]); + + // update + $builder->put( + '/{coid}/v2/person/{identifier}', + ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'update'] + ) + ->setPass(['coid', 'identifier']) + ->setPatterns([ + 'coid' => '[0-9]+', + 'identifier' => '[^/]+' + ]); + + // delete + $builder->delete( + '/{coid}/v2/person/{identifier}', + ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'delete'] + ) + ->setPass(['coid', 'identifier']) + ->setPatterns([ + 'coid' => '[0-9]+', + 'identifier' => '[^/]+' + ]); +}); diff --git a/app/plugins/CoreApi/config/plugin.json b/app/plugins/CoreApi/config/plugin.json index ccd52e420..ca7829c33 100644 --- a/app/plugins/CoreApi/config/plugin.json +++ b/app/plugins/CoreApi/config/plugin.json @@ -1,7 +1,8 @@ { "types": { "api": [ - "MatchCallbacks" + "MatchCallbacks", + "PersonProfiles" ] }, "schema": { @@ -19,7 +20,23 @@ "match_callbacks_i1": { "columns": [ "api_id" ]}, "match_callbacks_i2": { "needed": false, "columns": [ "server_id" ]} } + }, + "person_profiles": { + "columns": { + "id": {}, + "api_id": {}, + "status": { "type": "string", "size": 2 }, + "api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } }, + "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, + "index_response_type":{ "type": "string", "size": 2 }, + "expunge_on_delete": { "type": "boolean" } + }, + "indexes": { + "person_profiles_i1": { "columns": [ "api_id" ] }, + "person_profiles_i2": { "columns": [ "api_user_id" ] }, + "person_profiles_i3": { "columns": [ "identifier_type_id" ] } + } } } } -} \ No newline at end of file +} diff --git a/app/plugins/CoreApi/config/routes.php b/app/plugins/CoreApi/config/routes.php index dcb615142..f2adedfea 100644 --- a/app/plugins/CoreApi/config/routes.php +++ b/app/plugins/CoreApi/config/routes.php @@ -25,36 +25,6 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -use Cake\Http\Middleware\BodyParserMiddleware; -use Cake\Http\Middleware\CsrfProtectionMiddleware; -use Cake\Routing\Route\DashedRoute; -use Cake\Routing\RouteBuilder; -use Cake\Routing\Router; - // CoreApi API routes - -// Match Callback Receiver -// (https://spaces.at.internet2.edu/display/COmanage/Match+Resolution+Endpoint+Notification) -$routes->scope('/api/match', function (RouteBuilder $builder) { - // Register scoped middleware for in scopes. -// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients -// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); - // BodyParserMiddleware will automatically parse JSON bodies, but we only - // want that for API transactions, so we only apply it to the /api scope. - $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); - /* - * Apply a middleware to the current route scope. - * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` - */ -// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients -// $builder->applyMiddleware('csrf'); - $builder->setExtensions(['json']); - $builder->applyMiddleware('bodyparser'); - - $builder->post( - '/{coid}/v1/resolution', - ['plugin' => 'CoreApi', 'controller' => 'MatchCallbackApiV1', 'action' => 'resolveMatch'] - ) - ->setPass(['coid']) - ->setPatterns(['coid' => '[0-9]+']); -}); \ No newline at end of file +require __DIR__ . '/match_callbacks_routes.php'; +require __DIR__ . '/person_profiles_routes.php'; diff --git a/app/plugins/CoreApi/resources/locales/en_US/core_api.po b/app/plugins/CoreApi/resources/locales/en_US/core_api.po index fb902f2b1..048656f79 100644 --- a/app/plugins/CoreApi/resources/locales/en_US/core_api.po +++ b/app/plugins/CoreApi/resources/locales/en_US/core_api.po @@ -36,3 +36,45 @@ msgstr "Requested SOR Label not found" msgid "information.endpoint.match.callback" msgstr "The Match Resolution Notification (Callback) Endpoint for using this API is {0}" + +# Person Profils + +msgid "information.endpoint.person.profile" +msgstr "The Person Profiles Endpoint for using this API is {0}" + +msgid "controller.PersonProfiles" +msgstr "{0,plural,=1{Person Profile} other{Person Profiles}}" + +msgid "configuration.PersonProfiles.response.type" +msgstr "Response Type" + +msgid "configuration.PersonProfiles.expunge.on.delete" +msgstr "Person Expunge on Delete" + +msgid "field.PersonProfiles.identifier_type_id" +msgstr "Identifier Type" + +msgid "field.PersonProfiles.index_response_type" +msgstr "Response Type" + +msgid "field.PersonProfiles.expunge_on_delete" +msgstr "Person Expunge on Delete" + +msgid "field.PersonProfiles.api_user_id.desc" +msgstr "The API User authorized to make requests to this endpoint" + +msgid "field.PersonProfiles.identifier_type_id.desc" +msgstr "The Identifier type used to map API locate identifiers to CO Person records" + +msgid "field.PersonProfiles.index_response_type.desc" +msgstr "Define the response content granularity" + +msgid "field.PersonProfiles.expunge_on_delete.desc" +msgstr "If enabled, a delete request will expunge the person record instead of soft deleting it" + +msgid "enumeration.ResponseTypesEnum.FL" +msgstr "Full" + +msgid "enumeration.ResponseTypesEnum.IL" +msgstr "Identifier List" + diff --git a/app/plugins/CoreApi/src/Controller/PersonProfilesController.php b/app/plugins/CoreApi/src/Controller/PersonProfilesController.php new file mode 100644 index 000000000..520a6e61f --- /dev/null +++ b/app/plugins/CoreApi/src/Controller/PersonProfilesController.php @@ -0,0 +1,69 @@ + [ + 'PersonProfiles.api_user_id' => 'asc', + ], + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response|void + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if (!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->PersonProfiles->Apis->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->PersonProfiles->Apis->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->PersonProfiles->Apis->getPrimaryKey()); + } + + // Base endpoint (index/create); read/update/delete append "/{identifier}" + $this->set( + 'vv_api_endpoint', + Router::url('/', true) . 'api/person-profiles/' . $this->getCOID() . '/v2/person' + ); + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/CoreApi/src/Lib/Enum/ResponseTypesEnum.php b/app/plugins/CoreApi/src/Lib/Enum/ResponseTypesEnum.php new file mode 100644 index 000000000..cff1038db --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Enum/ResponseTypesEnum.php @@ -0,0 +1,38 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php new file mode 100644 index 000000000..a4d4a4629 --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -0,0 +1,152 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Apis'); + $this->belongsTo('ApiUsers'); + $this->belongsTo('IdentifierTypes') + ->setClassName('Types') + ->setForeignKey('identifier_type_id') + ->setProperty('identifier_type'); + + $this->setDisplayField('api_user_id'); + + // This is an Entry Point Model under the Api (pluggable) config + $this->setPrimaryLink('api_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + // Identifier Type configuration: + // - The column is identifier_type_id (FK -> types.id) + // - The type selector should be constrained to the Identifiers.type type family + $this->setAutoViewVars([ + 'apiUsers' => [ + 'type' => 'select', + 'model' => 'ApiUsers' + ], + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'indexResponseTypes' => [ + 'type' => 'enum', + 'class' => 'CoreApi.ResponseTypesEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.3.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator + { + $validator->add('api_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_id'); + + $this->registerStringValidation($validator, $this->getSchema(), 'status', true); + + $validator->add('api_user_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_user_id'); + + $validator->add('identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('identifier_type_id'); + + $validator->add('index_response_type', [ + 'content' => ['rule' => ['inList', ResponseTypesEnum::getConstValues()]] + ]); + + $validator->add('expunge_on_delete', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('expunge_on_delete'); + + return $validator; + } +} diff --git a/app/plugins/CoreApi/templates/PersonProfiles/fields.inc b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc new file mode 100644 index 000000000..c66731ccf --- /dev/null +++ b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc @@ -0,0 +1,52 @@ + 'information', + 'message' => __d('core_api', 'information.endpoint.person.profile', [$vv_api_endpoint]) + ] + ]; +} + +// Fields for person_profiles table +$fields = [ + 'status', + 'api_user_id', + 'identifier_type_id', + 'index_response_type', + 'expunge_on_delete' +]; + +$subnav = [ + 'tabs' => ['Apis', 'CoreApi.PersonProfiles'], + 'action' => [ + 'Apis' => ['edit'], + 'CoreApi.PersonProfiles' => ['edit'] + ] +]; \ No newline at end of file From bd5b7045b4bbb2168c0d779f1bc2ee4c5892c599 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 6 Jul 2026 01:56:34 +0000 Subject: [PATCH 02/16] fix configuration page display field --- .../resources/locales/en_US/core_api.po | 1 - .../src/Model/Table/PersonProfilesTable.php | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/plugins/CoreApi/resources/locales/en_US/core_api.po b/app/plugins/CoreApi/resources/locales/en_US/core_api.po index 048656f79..8d6379610 100644 --- a/app/plugins/CoreApi/resources/locales/en_US/core_api.po +++ b/app/plugins/CoreApi/resources/locales/en_US/core_api.po @@ -77,4 +77,3 @@ msgstr "Full" msgid "enumeration.ResponseTypesEnum.IL" msgstr "Identifier List" - diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php index a4d4a4629..6ee9aec62 100644 --- a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -36,9 +36,12 @@ class PersonProfilesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -74,6 +77,18 @@ public function initialize(array $config): void $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setEditContains([ + 'Apis', + 'ApiUsers', + 'IdentifierTypes' + ]); + + $this->setViewContains([ + 'Apis', + 'ApiUsers', + 'IdentifierTypes' + ]); + // Identifier Type configuration: // - The column is identifier_type_id (FK -> types.id) // - The type selector should be constrained to the Identifiers.type type family @@ -111,6 +126,17 @@ public function initialize(array $config): void ]); } + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.2.0 + * @param \CoreApi\Model\Entity\PersonProfile $entity Entity to generate display field for + * @return string Display field + */ + public function generateDisplayField(\CoreApi\Model\Entity\PersonProfile $entity): string { + return $entity->api->description; + } + /** * Set validation rules. * From 7c8fd2f5e709cab14f941963a96c7330e97508ce Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 6 Jul 2026 08:34:12 +0000 Subject: [PATCH 03/16] Added crud person actions. Added template for index and view. Added openapi yml and message. --- app/plugins/CoreApi/config/message.json | 421 +++++++++ app/plugins/CoreApi/config/openapi yaml | 868 ++++++++++++++++++ .../CoreApi/config/person_profiles_routes.php | 10 +- app/plugins/CoreApi/config/plugin.json | 2 - .../PersonProfileApiV2Controller.php | 477 ++++++++++ .../CoreApi/src/Lib/Utils/MessageFilter.php | 240 +++++ .../src/Model/Table/PersonProfilesTable.php | 137 ++- .../templates/PersonProfiles/fields.inc | 1 - .../PersonProfiles/json/person_profile.php | 379 ++++++++ 9 files changed, 2504 insertions(+), 31 deletions(-) create mode 100644 app/plugins/CoreApi/config/message.json create mode 100644 app/plugins/CoreApi/config/openapi yaml create mode 100644 app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php create mode 100644 app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php create mode 100644 app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php diff --git a/app/plugins/CoreApi/config/message.json b/app/plugins/CoreApi/config/message.json new file mode 100644 index 000000000..72ece2b5c --- /dev/null +++ b/app/plugins/CoreApi/config/message.json @@ -0,0 +1,421 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.invalid/comanage/coreapi/v5/message.json", + "title": "COmanage Core API Message Format (v5)", + "description": "COmanage Core API Message Format (v5) for Person Profiles API v2", + + "definitions": { + "meta": { + "type": "object", + "description": "Metadata returned on read (GET). Most fields are read-only.", + "properties": { + "id": { + "description": "COmanage identifier for this object", + "type": "integer", + "readOnly": true + }, + "actor_identifier": { + "description": "The identifier for the actor who last modified this object", + "type": "string", + "readOnly": true + }, + "created": { + "description": "When this object was originally created", + "type": "string", + "format": "date-time", + "readOnly": true + }, + "deleted": { + "description": "Whether this object has been deleted", + "type": "boolean", + "readOnly": true + }, + "modified": { + "description": "When this object was last modified", + "type": "string", + "format": "date-time", + "readOnly": true + }, + "revision": { + "description": "The revision number for this object", + "type": "integer", + "minimum": 0, + "readOnly": true + } + }, + "patternProperties": { + "^source_.*_id$": { + "description": "The source for this record, if created via Pipeline", + "type": "integer", + "readOnly": true + }, + ".*_id$": { + "description": "Object parent key (foreign key), for changelog/tracking", + "type": "integer", + "readOnly": true + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + + "type": "object", + "properties": { + "Person": { + "type": "object", + "description": "Person object (v5 equivalent of v4 CoPerson).", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "co_id": { + "description": "CO ID for this Person", + "type": "integer", + "readOnly": true + }, + "date_of_birth": { + "description": "Person date of birth", + "type": "string", + "format": "date" + }, + "status": { + "description": "Person status", + "type": "string" + }, + "timezone": { + "description": "Preferred timezone of this Person, for UI purposes", + "type": "string" + } + }, + "required": ["co_id", "status"], + "additionalProperties": false + }, + + "GroupMember": { + "type": "array", + "description": "Memberships of the Person in Groups.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "co_group_id": { + "description": "Group ID for this membership", + "type": "integer", + "readOnly": true + }, + "member": { + "description": "If this Person is a member of this group", + "type": "boolean" + }, + "owner": { + "description": "If this Person is an owner of this group", + "type": "boolean" + }, + "co_group_nesting_id": { + "description": "Group nesting that created this membership, if set", + "type": "integer", + "readOnly": true + } + }, + "required": ["co_group_id"], + "additionalProperties": false + } + }, + + "PersonRole": { + "type": "array", + "description": "Roles for the Person (v5 equivalent of v4 CoPersonRole).", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "cou_id": { "description": "COU for this Role", "type": "integer" }, + "title": { "description": "Title for this Role", "type": "string" }, + "o": { "description": "Organization for this Role", "type": "string" }, + "ou": { "description": "Department for this Role", "type": "string" }, + "valid_from": { + "description": "Valid from time for this Role", + "type": "string", + "format": "date-time" + }, + "valid_through": { + "description": "Valid through time for this Role", + "type": "string", + "format": "date-time" + }, + "status": { "description": "Person Role status", "type": "string" }, + "sponsor_person_id": { + "description": "Sponsor Person ID for this Role", + "type": "integer" + }, + "affiliation": { "description": "Person Role affiliation", "type": "string" }, + "ordr": { + "description": "Order of this Role, relative to other roles for this person", + "type": "integer" + }, + + "Address": { + "type": "array", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "country": { "description": "Country for this Address", "type": "string" }, + "description": { "description": "Description of this Address", "type": "string" }, + "language": { "description": "Language encoding of this Address", "type": "string" }, + "locality": { "description": "Locality (eg: city) of this Address", "type": "string" }, + "postal_code": { "description": "Postal code of this Address", "type": "string" }, + "room": { "description": "Room associated with this Address", "type": "string" }, + "state": { "description": "State of this Address", "type": "string" }, + "street": { "description": "Street of this Address", "type": "string" }, + "type": { "description": "Type of this Address", "type": "string" } + }, + "required": [], + "additionalProperties": false + } + }, + + "AdHocAttribute": { + "type": "array", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "tag": { "description": "Tag for this Ad Hoc Attribute", "type": "string" }, + "value": { "description": "Value of this Ad Hoc Attribute", "type": "string" } + }, + "required": ["tag"], + "additionalProperties": false + } + }, + + "TelephoneNumber": { + "type": "array", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "country_code": { "description": "Country code for this Telephone Number", "type": "string" }, + "area_code": { "description": "Area code for this Telephone Number", "type": "string" }, + "number": { "description": "Number for this Telephone Number", "type": "string" }, + "extension": { "description": "Extension for this Telephone Number", "type": "string" }, + "description": { "description": "Description of this Telephone Number", "type": "string" }, + "type": { "description": "Type of this Telephone Number", "type": "string" } + }, + "required": ["number"], + "additionalProperties": false + } + } + }, + "required": ["affiliation", "status"], + "additionalProperties": false + } + }, + + "EmailAddress": { + "type": "array", + "description": "Email addresses for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "mail": { + "description": "An email address for this person", + "type": "string", + "format": "email" + }, + "type": { "description": "The type of Email Address", "type": "string" }, + "verified": { + "description": "Whether this Email Address has been verified", + "type": "boolean" + } + }, + "required": ["mail"], + "additionalProperties": false + } + }, + + "Identifier": { + "type": "array", + "description": "Identifiers for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "identifier": { "description": "An identifier for the person", "type": "string" }, + "login": { + "description": "Whether this Identifier can be used to login to Registry", + "type": "boolean" + }, + "status": { "description": "Identifier status", "type": "string" }, + "type": { "description": "The type of Identifier", "type": "string" } + }, + "required": ["identifier"], + "additionalProperties": false + } + }, + + "Name": { + "type": "array", + "description": "Names for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "family": { "description": "The family or surname", "type": "string" }, + "formatted": { "description": "The fully formatted name", "type": "string" }, + "given": { "description": "The given or first name", "type": "string" }, + "language": { "description": "The language encoding for this Name", "type": "string" }, + "middle": { "description": "The middle name", "type": "string" }, + "prefix": { "description": "The honorific or prefix for the Name", "type": "string" }, + "primary_name": { "description": "Whether this is the primary Name", "type": "boolean" }, + "suffix": { "description": "The suffix for this Name", "type": "string" }, + "type": { "description": "The type of Name", "type": "string" } + }, + "required": ["given"], + "additionalProperties": false + } + }, + + "Url": { + "type": "array", + "description": "URLs for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "description": { "description": "Description of this URL", "type": "string" }, + "url": { "description": "A URL", "type": "string", "format": "uri" }, + "type": { "description": "The type of URL", "type": "string" } + }, + "required": ["url"], + "additionalProperties": false + } + }, + + "SshKey": { + "type": "array", + "description": "SSH keys for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "comment": { "description": "Comment for this SSH Key", "type": "string" }, + "type": { "description": "SSH Key type", "type": "string" }, + "skey": { "description": "SSH Key", "type": "string" }, + "ssh_key_authenticator_id": { + "description": "SSH Key Authenticator ID associated with this SSH Key", + "type": "integer", + "readOnly": true + } + }, + "required": ["type", "skey"], + "additionalProperties": false + } + }, + + "Certificate": { + "type": "array", + "description": "Certificates for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "description": { "description": "Description of this Certificate", "type": "string" }, + "subject_dn": { "description": "Subject DN of this Certificate", "type": "string" }, + "issuer_in": { "description": "Issuer DN of this Certificate", "type": "string" }, + "valid_from": { "description": "Valid from time for this Certificate", "type": "string", "format": "date-time" }, + "valid_through": { "description": "Valid through time for this Certificate", "type": "string", "format": "date-time" }, + "certificate_authenticator_id": { + "description": "Certificate Authenticator ID associated with this Certificate", + "type": "integer", + "readOnly": true + } + }, + "required": ["subject_dn"], + "additionalProperties": false + } + }, + + "Password": { + "type": "array", + "description": "Passwords for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "password": { "description": "Password", "type": "string" }, + "password_type": { "description": "Password (hash) type", "type": "string" }, + "password_authenticator_id": { + "description": "Password Authenticator ID associated with this Password", + "type": "integer", + "readOnly": true + } + }, + "required": ["password", "password_type"], + "additionalProperties": false + } + }, + + "UnixClusterAccount": { + "type": "array", + "description": "Unix cluster accounts for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "sync_mode": { "description": "Sync Mode for this Unix Cluster Account", "type": "string" }, + "status": { "description": "Status for this Unix Cluster Account", "type": "string" }, + "username": { "description": "Username for this Unix Cluster Account", "type": "string" }, + "uid": { "description": "UID for this Unix Cluster Account", "type": "string" }, + "gecos": { "description": "GECOS for this Unix Cluster Account", "type": "string" }, + "login_shell": { "description": "Login shell for this Unix Cluster Account", "type": "string" }, + "home_directory": { "description": "Home directory for this Unix Cluster Account", "type": "string" }, + "primary_co_group_id": { "description": "Primary group for this Unix Cluster Account", "type": "string" }, + "valid_from": { "description": "Valid from time for this Unix Cluster Account", "type": "string", "format": "date-time" }, + "valid_through": { "description": "Valid through time for this Unix Cluster Account", "type": "string", "format": "date-time" }, + "unix_cluster_id": { + "description": "Unix Cluster ID associated with this Unix Cluster Account", + "type": "integer", + "readOnly": true + } + }, + "required": [], + "additionalProperties": false + } + }, + + "ExternalIdentity": { + "type": "array", + "description": "External identities linked to the Person (read-only in this API surface).", + "readOnly": true, + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "co_id": { "description": "CO for this External Identity", "type": "integer" }, + "title": { "description": "Title for this External Identity", "type": "string" }, + "o": { "description": "Organization for this External Identity", "type": "string" }, + "ou": { "description": "Department for this External Identity", "type": "string" }, + "valid_from": { "description": "Valid from time for this External Identity", "type": "string", "format": "date-time" }, + "valid_through": { "description": "Valid through time for this External Identity", "type": "string", "format": "date-time" }, + "status": { "description": "External Identity status", "type": "string" }, + "affiliation": { "description": "External Identity affiliation", "type": "string" }, + "date_of_birth": { "description": "External Identity date of birth", "type": "string", "format": "date" }, + + "Address": { "$ref": "#/properties/PersonRole/items/properties/Address" }, + "AdHocAttribute": { "$ref": "#/properties/PersonRole/items/properties/AdHocAttribute" }, + "EmailAddress": { "$ref": "#/properties/EmailAddress" }, + "Identifier": { "$ref": "#/properties/Identifier" }, + "Name": { "$ref": "#/properties/Name" }, + "TelephoneNumber": { "$ref": "#/properties/PersonRole/items/properties/TelephoneNumber" }, + "Url": { "$ref": "#/properties/Url" } + }, + "required": [], + "additionalProperties": false + } + } + }, + + "required": ["Person"], + "additionalProperties": false +} diff --git a/app/plugins/CoreApi/config/openapi yaml b/app/plugins/CoreApi/config/openapi yaml new file mode 100644 index 000000000..a063b5d46 --- /dev/null +++ b/app/plugins/CoreApi/config/openapi yaml @@ -0,0 +1,868 @@ +openapi: 3.0.3 +info: + title: COmanage Registry Core API (v5) - Person Profiles API v2 + description: | + Transaction-oriented Core API for COmanage Registry v5. + + This specification documents the Person Profiles API v2 endpoints: + + /api/person-profiles/{coid}/v2/person + /api/person-profiles/{coid}/v2/person/{identifier} + + Query parameters **identifier**, **direction**, **limit**, and **page** are supported + for compatibility with the v4 Core API People endpoint. + + External identities are returned on GET (read-only) but are not supported for editing + via POST/PUT. + contact: + name: COmanage Project + url: https://spaces.at.internet2.edu/display/COmanage/About+the+COmanage+Project + email: comanage-users@internet2.edu + license: + name: APACHE LICENSE, VERSION 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.0 +servers: + - url: https://localhost/registry + description: | + Most deployments serve Registry using the default path /registry. If your deployment changes the + default path you must adjust the relative URIs. + +tags: + - name: PersonProfiles + description: Person Profiles API v2 + +paths: + /api/person-profiles/{coid}/v2/person: + get: + tags: [PersonProfiles] + summary: Retrieve one or more People and related objects + description: | + Use the **identifier** query parameter to retrieve a single Person Profile (by identifier value). + With no query parameters, retrieve all People in the CO. + + Use **direction**, **limit**, and **page** to control ordering and pagination. + + Note: The detailed semantics of identifier resolution are deployment/configuration dependent. + operationId: getPersonProfiles + parameters: + - name: coid + in: path + description: CO ID + required: true + schema: + type: integer + minimum: 1 + - name: identifier + in: query + description: Person Identifier (configured identifier type for this API) + required: false + schema: + type: string + - name: direction + in: query + description: asc (older records first) or desc (newer records first) + required: false + schema: + type: string + enum: [asc, desc] + - name: limit + in: query + description: The maximum number of records to return + required: false + schema: + type: integer + minimum: 1 + - name: page + in: query + description: Return this page of the result set + required: false + schema: + type: integer + minimum: 1 + - name: People.status + in: query + description: Optional filter by Person status (string value) + required: false + schema: + type: string + responses: + "200": + $ref: "#/components/responses/PagedPersonProfileMessage" + "401": + description: Unauthorized + "404": + description: Not Found + "500": + description: Server Error + + post: + tags: [PersonProfiles] + summary: Create a Person + description: | + Create a Person and related objects. + + Note: External identities are not supported for editing and are ignored if provided. + operationId: addPersonProfile + parameters: + - name: coid + in: path + description: CO ID + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfileMessageWrite" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedId" + "400": + description: Bad Request + "401": + description: Unauthorized + "404": + description: Not Found + + /api/person-profiles/{coid}/v2/person/{identifier}: + get: + tags: [PersonProfiles] + summary: Retrieve a Person + description: Retrieve a single Person Profile by identifier. + operationId: readPersonProfile + parameters: + - name: coid + in: path + description: CO ID + required: true + schema: + type: integer + minimum: 1 + - name: identifier + in: path + description: Person Identifier (configured identifier type for this API) + required: true + schema: + type: string + responses: + "200": + description: Person profile read response object + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfileMessageRead" + "401": + description: Unauthorized + "404": + description: Not Found + "500": + description: Server Error + + put: + tags: [PersonProfiles] + summary: Update a Person + description: | + Update a Person and related objects by identifier. + + Note: External identities are not supported for editing and are ignored if provided. + operationId: updatePersonProfile + parameters: + - name: coid + in: path + description: CO ID + required: true + schema: + type: integer + minimum: 1 + - name: identifier + in: path + description: Person Identifier (configured identifier type for this API) + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfileMessageWrite" + responses: + "200": + description: Updated + "400": + description: Bad Request + "401": + description: Unauthorized + "404": + description: Not Found + + delete: + tags: [PersonProfiles] + summary: Delete a Person + description: | + Delete a Person and related objects by identifier. + + Whether the delete is soft or hard (expunge) is configurable. + operationId: deletePersonProfile + parameters: + - name: coid + in: path + description: CO ID + required: true + schema: + type: integer + minimum: 1 + - name: identifier + in: path + description: Person Identifier (configured identifier type for this API) + required: true + schema: + type: string + responses: + "200": + description: Deleted + "401": + description: Unauthorized + "404": + description: Not Found + "500": + description: Server Error + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + + schemas: + Meta: + type: object + description: | + Metadata about objects returned when reading (GET). + + Most metadata is read-only and will be ignored on write operations (POST/PUT). + properties: + id: + description: COmanage identifier for this object + type: integer + readOnly: true + actor_identifier: + description: The identifier for the actor who last modified this object + type: string + readOnly: true + created: + description: When this object was originally created + type: string + format: date-time + readOnly: true + deleted: + description: Whether this object has been deleted + type: boolean + readOnly: true + modified: + description: When this object was last modified + type: string + format: date-time + readOnly: true + revision: + description: The revision number for this object + type: integer + minimum: 0 + readOnly: true + required: [id] + additionalProperties: false + + Person: + type: object + description: Person object (v5 equivalent of v4 CoPerson). + properties: + meta: + $ref: "#/components/schemas/Meta" + co_id: + description: CO ID for this Person + type: integer + readOnly: true + date_of_birth: + description: Person date of birth + type: string + format: date + status: + description: Person status + type: string + timezone: + description: Preferred timezone of this Person, for UI purposes + type: string + required: [co_id, status] + additionalProperties: false + + Name: + type: array + items: + type: object + description: Name for a Person + properties: + meta: + $ref: "#/components/schemas/Meta" + family: + description: The family or surname + type: string + formatted: + description: The fully formatted name + type: string + given: + description: The given or first name + type: string + language: + description: The language encoding for this Name + type: string + middle: + description: The middle name + type: string + prefix: + description: The honorific or prefix for the Name + type: string + primary_name: + description: Whether this is the primary Name + type: boolean + suffix: + description: The suffix for this Name + type: string + type: + description: The type of Name + type: string + required: [given] + additionalProperties: false + + EmailAddress: + type: array + items: + type: object + description: Email address for a Person + properties: + meta: + $ref: "#/components/schemas/Meta" + mail: + description: An email address for this person + type: string + format: email + type: + description: The type of Email Address + type: string + verified: + description: Whether this Email Address has been verified + type: boolean + required: [mail] + additionalProperties: false + + Identifier: + type: array + items: + type: object + description: Identifier for a Person + properties: + meta: + $ref: "#/components/schemas/Meta" + identifier: + description: An identifier for the person + type: string + login: + description: Whether this Identifier can be used to login to Registry + type: boolean + status: + description: Identifier status + type: string + type: + description: The type of Identifier + type: string + required: [identifier] + additionalProperties: false + + Url: + type: array + items: + type: object + description: URL for a Person + properties: + meta: + $ref: "#/components/schemas/Meta" + description: + description: Description of this URL + type: string + url: + description: A URL + type: string + format: uri + type: + description: The type of URL + type: string + required: [url] + additionalProperties: false + + GroupMember: + type: array + items: + type: object + description: Membership of Person in a Group + properties: + meta: + $ref: "#/components/schemas/Meta" + co_group_id: + description: Group ID for this membership + type: integer + readOnly: true + member: + description: If this Person is a member of this group + type: boolean + owner: + description: If this Person is an owner of this group + type: boolean + co_group_nesting_id: + description: Group nesting that created this membership, if set + type: integer + readOnly: true + required: [co_group_id] + additionalProperties: false + + Address: + type: array + items: + type: object + description: Postal address + properties: + meta: + $ref: "#/components/schemas/Meta" + country: + description: Country for this Address + type: string + description: + description: Description of this Address + type: string + language: + description: Language encoding of this Address + type: string + locality: + description: | + Locality (eg: city) of this Address + type: string + postal_code: + description: Postal code of this Address + type: string + room: + description: Room associated with this Address + type: string + state: + description: State of this Address + type: string + street: + description: Street of this Address + type: string + type: + description: Type of this Address + type: string + required: [] + additionalProperties: false + + AdHocAttribute: + type: array + items: + type: object + description: An ad-hoc attribute + properties: + meta: + $ref: "#/components/schemas/Meta" + tag: + description: Tag for this Ad Hoc Attribute + type: string + value: + description: Value of this Ad Hoc Attribute + type: string + required: [tag] + additionalProperties: false + + TelephoneNumber: + type: array + items: + type: object + description: Telephone number + properties: + meta: + $ref: "#/components/schemas/Meta" + country_code: + description: Country code for this Telephone Number + type: string + area_code: + description: Area code for this Telephone Number + type: string + number: + description: Number for this Telephone Number + type: string + extension: + description: Extension for this Telephone Number + type: string + description: + description: Description of this Telephone Number + type: string + type: + description: Type of this Telephone Number + type: string + required: [number] + additionalProperties: false + + PersonRole: + type: array + items: + type: object + description: Role for a Person (v5 equivalent of v4 CoPersonRole) + properties: + meta: + $ref: "#/components/schemas/Meta" + cou_id: + description: COU for this Role + type: integer + title: + description: Title for this Role + type: string + o: + description: Organization for this Role + type: string + ou: + description: Department for this Role + type: string + valid_from: + description: Valid from time for this Role + type: string + format: date-time + valid_through: + description: Valid through time for this Role + type: string + format: date-time + status: + description: Person Role status + type: string + sponsor_person_id: + description: Sponsor Person ID for this Role + type: integer + affiliation: + description: Person Role affiliation + type: string + ordr: + description: Order of this Role, relative to other roles for this person + type: integer + Address: + $ref: "#/components/schemas/Address" + AdHocAttribute: + $ref: "#/components/schemas/AdHocAttribute" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" + required: [affiliation, status] + additionalProperties: false + + # Read-only external identity (v4 OrgIdentity), returned on GET only. + ExternalIdentity: + type: array + description: | + External identities associated with the Person. + + Read-only in this API surface: returned on GET responses, ignored/not supported on POST/PUT. + items: + type: object + properties: + meta: + $ref: "#/components/schemas/Meta" + co_id: + description: CO for this External Identity + type: integer + title: + description: Title for this External Identity + type: string + o: + description: Organization for this External Identity + type: string + ou: + description: Department for this External Identity + type: string + valid_from: + description: Valid from time for this External Identity + type: string + format: date-time + valid_through: + description: Valid through time for this External Identity + type: string + format: date-time + status: + description: External Identity status + type: string + affiliation: + description: External Identity affiliation + type: string + date_of_birth: + description: External Identity date of birth + type: string + format: date + Address: + $ref: "#/components/schemas/Address" + AdHocAttribute: + $ref: "#/components/schemas/AdHocAttribute" + EmailAddress: + $ref: "#/components/schemas/EmailAddress" + Identifier: + $ref: "#/components/schemas/Identifier" + Name: + $ref: "#/components/schemas/Name" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" + Url: + $ref: "#/components/schemas/Url" + required: [] + additionalProperties: false + + SshKey: + type: array + items: + type: object + description: Object representing an SSH key + properties: + meta: + $ref: "#/components/schemas/Meta" + comment: + description: Comment for this SSH Key + type: string + type: + description: SSH Key type + type: string + skey: + description: SSH Key + type: string + ssh_key_authenticator_id: + description: SSH Key Authenticator ID associated with this SSH Key + type: integer + readOnly: true + required: [type, skey] + additionalProperties: false + + Certificate: + type: array + items: + type: object + description: Certificate + properties: + meta: + $ref: "#/components/schemas/Meta" + description: + description: Description of this Certificate + type: string + subject_dn: + description: Subject DN of this Certificate + type: string + issuer_in: + description: Issuer DN of this Certificate + type: string + valid_from: + description: Valid from time for this Certificate + type: string + format: date-time + valid_through: + description: Valid through time for this Certificate + type: string + format: date-time + certificate_authenticator_id: + description: Certificate Authenticator ID associated with this Certificate + type: integer + readOnly: true + required: [subject_dn] + additionalProperties: false + + Password: + type: array + items: + type: object + description: Password + properties: + meta: + $ref: "#/components/schemas/Meta" + password: + description: Password + type: string + password_type: + description: Password (hash) type + type: string + password_authenticator_id: + description: Password Authenticator ID associated with this Password + type: integer + readOnly: true + required: [password, password_type] + additionalProperties: false + + UnixClusterAccount: + type: array + items: + type: object + description: Unix Cluster Account + properties: + meta: + $ref: "#/components/schemas/Meta" + sync_mode: + description: Sync Mode for this Unix Cluster Account + type: string + status: + description: Status for this Unix Cluster Account + type: string + username: + description: Username for this Unix Cluster Account + type: string + uid: + description: UID for this Unix Cluster Account + type: string + gecos: + description: GECOS for this Unix Cluster Account + type: string + login_shell: + description: Login shell for this Unix Cluster Account + type: string + home_directory: + description: Home directory for this Unix Cluster Account + type: string + primary_co_group_id: + description: Primary group for this Unix Cluster Account + type: string + valid_from: + description: Valid from time for this Unix Cluster Account + type: string + format: date-time + valid_through: + description: Valid through time for this Unix Cluster Account + type: string + format: date-time + unix_cluster_id: + description: Unix Cluster ID associated with this Unix Cluster Account + type: integer + readOnly: true + required: [] + additionalProperties: false + + PersonProfileMessageRead: + type: object + description: Collection of a Person and related objects (read form) + properties: + Person: + $ref: "#/components/schemas/Person" + GroupMember: + $ref: "#/components/schemas/GroupMember" + EmailAddress: + $ref: "#/components/schemas/EmailAddress" + PersonRole: + $ref: "#/components/schemas/PersonRole" + Identifier: + $ref: "#/components/schemas/Identifier" + Name: + $ref: "#/components/schemas/Name" + SshKey: + $ref: "#/components/schemas/SshKey" + Url: + $ref: "#/components/schemas/Url" + Certificate: + $ref: "#/components/schemas/Certificate" + Password: + $ref: "#/components/schemas/Password" + UnixClusterAccount: + $ref: "#/components/schemas/UnixClusterAccount" + ExternalIdentity: + $ref: "#/components/schemas/ExternalIdentity" + required: [Person] + additionalProperties: false + + PersonProfileMessageWrite: + type: object + description: | + Collection of a Person and related objects (write form). + + Note: ExternalIdentity is not supported for editing via this API surface. + properties: + Person: + $ref: "#/components/schemas/Person" + GroupMember: + $ref: "#/components/schemas/GroupMember" + EmailAddress: + $ref: "#/components/schemas/EmailAddress" + PersonRole: + $ref: "#/components/schemas/PersonRole" + Identifier: + $ref: "#/components/schemas/Identifier" + Name: + $ref: "#/components/schemas/Name" + SshKey: + $ref: "#/components/schemas/SshKey" + Url: + $ref: "#/components/schemas/Url" + Certificate: + $ref: "#/components/schemas/Certificate" + Password: + $ref: "#/components/schemas/Password" + UnixClusterAccount: + $ref: "#/components/schemas/UnixClusterAccount" + required: [Person] + additionalProperties: false + + CreatedId: + type: object + properties: + id: + type: integer + required: [id] + additionalProperties: false + + responses: + PagedPersonProfileMessage: + description: Paged collection of PersonProfileMessage objects indexed by integer values + content: + application/json: + schema: + type: object + properties: + 0: + $ref: "#/components/schemas/PersonProfileMessageRead" + description: Person profile read response object + currentPage: + description: current page + type: string + readOnly: true + example: "1" + itemsPerPage: + description: items per page + type: string + readOnly: true + example: "1" + pageCount: + description: page count + type: string + readOnly: true + example: "1" + startIndex: + description: start index + type: string + readOnly: true + example: "1" + totalResults: + description: total count of results + type: string + readOnly: true + example: "1" + additionalProperties: + type: array + items: + $ref: "#/components/schemas/PersonProfileMessageRead" + +security: + - basicAuth: [] + +externalDocs: + description: COmanage Registry Core API + url: https://spaces.at.internet2.edu/display/COmanage/Core+API diff --git a/app/plugins/CoreApi/config/person_profiles_routes.php b/app/plugins/CoreApi/config/person_profiles_routes.php index c14d3dfa0..0657e545c 100644 --- a/app/plugins/CoreApi/config/person_profiles_routes.php +++ b/app/plugins/CoreApi/config/person_profiles_routes.php @@ -38,7 +38,7 @@ // index (list) $builder->get( '/{coid}/v2/person', - ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'index'] + ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'index'] ) ->setPass(['coid']) ->setPatterns(['coid' => '[0-9]+']); @@ -46,7 +46,7 @@ // create $builder->post( '/{coid}/v2/person', - ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'create'] + ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'create'] ) ->setPass(['coid']) ->setPatterns(['coid' => '[0-9]+']); @@ -54,7 +54,7 @@ // read $builder->get( '/{coid}/v2/person/{identifier}', - ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'read'] + ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'read'] ) ->setPass(['coid', 'identifier']) ->setPatterns([ @@ -65,7 +65,7 @@ // update $builder->put( '/{coid}/v2/person/{identifier}', - ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'update'] + ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'update'] ) ->setPass(['coid', 'identifier']) ->setPatterns([ @@ -76,7 +76,7 @@ // delete $builder->delete( '/{coid}/v2/person/{identifier}', - ['plugin' => 'CoreApi', 'controller' => 'PersonProfilesApiV2', 'action' => 'delete'] + ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'delete'] ) ->setPass(['coid', 'identifier']) ->setPatterns([ diff --git a/app/plugins/CoreApi/config/plugin.json b/app/plugins/CoreApi/config/plugin.json index ca7829c33..15e69d47e 100644 --- a/app/plugins/CoreApi/config/plugin.json +++ b/app/plugins/CoreApi/config/plugin.json @@ -26,14 +26,12 @@ "id": {}, "api_id": {}, "status": { "type": "string", "size": 2 }, - "api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } }, "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, "index_response_type":{ "type": "string", "size": 2 }, "expunge_on_delete": { "type": "boolean" } }, "indexes": { "person_profiles_i1": { "columns": [ "api_id" ] }, - "person_profiles_i2": { "columns": [ "api_user_id" ] }, "person_profiles_i3": { "columns": [ "identifier_type_id" ] } } } diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php new file mode 100644 index 000000000..5c31c2b87 --- /dev/null +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -0,0 +1,477 @@ + + */ + public $entryPointMap = [ + 'index' => 'PersonProfiles', + 'create' => 'PersonProfiles', + 'read' => 'PersonProfiles', + 'update' => 'PersonProfiles', + 'delete' => 'PersonProfiles', + // Aliases / internal dispatch + 'post' => 'PersonProfiles', + 'put' => 'PersonProfiles', + 'upsert' => 'PersonProfiles', + ]; + + /** + * Callback run prior to the request rendering. + * + * @since COmanage Registry v5.3.0 + * @param EventInterface $event Cake Event + * @return void + */ + public function beforeRender(EventInterface $event): void + { + // If we set 'serialize', JsonView will skip templates entirely. + // For index/read we want the person_profile template to run. + $action = (string)$this->request->getParam('action'); + + if (in_array($action, ['index', 'read'], true)) { + // Template will echo JSON, so do not use JsonView serialization. + $this->viewBuilder()->setOption('serialize', null); + $this->viewBuilder()->setClassName(View::class); + $this->viewBuilder()->setLayout(null); + $this->viewBuilder()->disableAutoLayout(); + } else { + // Keep current behavior for non-profile actions + $this->viewBuilder()->setOption('serialize', 'vv_results'); + } + parent::beforeRender($event); + } + + /** + * Calculate the CO ID associated with the request. + * + * @since COmanage Registry v5.3.0 + * @return int CO ID, or null if no CO context was found + */ + public function calculateRequestedCOID(): ?int + { + $coid = $this->request->getParam('coid'); + + return ($coid !== null && ctype_digit((string)$coid)) ? (int)$coid : null; + } + + /** + * Handle an index request. + * + * Supports query parameters: + * - identifier: resolve and return a single Person Profile (paged envelope, 1 result) + * - direction: asc|desc (by People.id) + * - limit: page size + * - page: page number (1-based) + * - People.status: optional filter + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @return void + */ + public function index(string $coid): void + { + $People = $this->getPeopleTable(); + + try { + $coId = (int)$coid; + + $identifier = $this->request->getQuery('identifier'); + if (is_string($identifier) && $identifier !== '') { + $personId = $this->resolvePersonId($coId, $identifier); + $person = $this->findPersonWithProfileContain($coId, $personId); + + $this->viewBuilder() + ->setPlugin('CoreApi') + ->setTemplatePath('PersonProfiles/json') + ->setTemplate('person_profile'); + + $this->set('vv_people', [$person]); + $this->set('vv_page', 1); + $this->set('vv_limit', 1); + $this->set('vv_total', 1); + + $this->response = $this->response->withStatus(200); + return; + } + + $direction = strtolower((string)$this->request->getQuery('direction', 'asc')); + $direction = ($direction === 'desc') ? 'DESC' : 'ASC'; + + $limit = (int)$this->request->getQuery('limit', 100); + if ($limit < 1) { + $limit = 100; + } + + $page = (int)$this->request->getQuery('page', 1); + if ($page < 1) { + $page = 1; + } + + $statusFilter = $this->request->getQuery('People.status'); + $conditions = ['People.co_id' => $coId]; + if (is_string($statusFilter) && $statusFilter !== '') { + $conditions['People.status'] = $statusFilter; + } + + $baseQuery = $People->find()->where($conditions); + $total = (int)$baseQuery->count(); + + $contain = $this->getPersonProfilesTable()->getPersonProfileContain(); + + $people = $baseQuery + ->orderBy(['People.id' => $direction]) + ->limit($limit) + ->offset(($page - 1) * $limit) + ->contain($contain) + ->all() + ->toList(); + + $this->viewBuilder() + ->setPlugin('CoreApi') + ->setTemplatePath('PersonProfiles/json') + ->setTemplate('person_profile'); + + $this->set('vv_people', $people); + $this->set('vv_page', $page); + $this->set('vv_limit', $limit); + $this->set('vv_total', $total); + + $this->response = $this->response->withStatus(200); + return; + } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $this->response = $this->response->withStatus(404); + $this->set('vv_results', ['error' => $e->getMessage()]); + return; + } catch (\Exception $e) { + $this->llog('debug', $e->getMessage()); + $this->response = $this->response->withStatus(500); + $this->set('vv_results', ['error' => $e->getMessage()]); + return; + } + } + + /** + * Handle a create request. + * + * Routed as POST /api/person-profiles/{coid}/v2/person + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @return void + */ + public function create(string $coid): void + { + $this->post($coid); + } + + /** + * Handle a read request. + * + * Routed as GET /api/person-profiles/{coid}/v2/person/{identifier} + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @param string $identifier Person identifier + * @return void + */ + public function read(string $coid, string $identifier): void + { + try { + $coId = (int)$coid; + $personId = $this->resolvePersonId($coId, $identifier); + $person = $this->findPersonWithProfileContain($coId, $personId); + + $this->viewBuilder() + ->setPlugin('CoreApi') + ->setTemplatePath('PersonProfiles/json') + ->setTemplate('person_profile'); + + $this->set('vv_person', $person); + $this->response = $this->response->withStatus(200); + return; + } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $this->response = $this->response->withStatus(404); + $this->set('vv_results', ['error' => $e->getMessage()]); + return; + } catch (\Exception $e) { + $this->llog('debug', $e->getMessage()); + $this->response = $this->response->withStatus(500); + $this->set('vv_results', ['error' => $e->getMessage()]); + return; + } + } + + /** + * Handle an update request. + * + * Routed as PUT /api/person-profiles/{coid}/v2/person/{identifier} + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @param string $identifier Person identifier + * @return void + */ + public function update(string $coid, string $identifier): void + { + $this->put($coid, $identifier); + } + + /** + * Handle a create request (alias for create()). + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @return void + */ + public function post(string $coid): void + { + $this->upsert(coid: $coid, identifier: null); + } + + /** + * Handle an update request (alias for update()). + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @param string $identifier Person identifier + * @return void + */ + public function put(string $coid, string $identifier): void + { + $this->upsert(coid: $coid, identifier: $identifier); + } + + /** + * Handle a create or update request. + * + * Note: this currently filters metadata only for the primary "Person" record. + * Related model write support should be added by extracting and filtering those + * blocks separately (using MessageFilter) before marshalling to entities. + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @param string|null $identifier Person identifier (null for create) + * @return void + */ + public function upsert(string $coid, ?string $identifier = null): void + { + $People = $this->getPeopleTable(); + + $payload = $this->request->getData(); + if (empty($payload) || !is_array($payload)) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + // Accept either { "Person": {...} } (spec), { "person": {...} } (legacy), or a flat payload. + $personDataRaw = $payload['Person'] ?? $payload['person'] ?? $payload; + + if (!is_array($personDataRaw)) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + // Filter inbound metadata for the primary record + $personData = MessageFilter::filterMetadataInbound($personDataRaw, 'Person'); + + $results = []; + $resultCode = 400; + + try { + if ($identifier === null) { + $entity = $People->newEntity($personData); + $entity->co_id = (int)$coid; + + $People->saveOrFail($entity); + + $resultCode = 201; + $results = ['id' => $entity->id]; + } else { + $personId = $this->resolvePersonId((int)$coid, $identifier); + + $entity = $People->findById($personId)->firstOrFail(); + $entity = $People->patchEntity($entity, $personData); + + $People->saveOrFail($entity); + + $resultCode = 200; + $results = ['id' => $entity->id]; + } + } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results = ['error' => $e->getMessage()]; + } catch (\Exception $e) { + $this->llog('debug', $e->getMessage()); + $resultCode = 400; + $results = ['error' => $e->getMessage()]; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle a delete request. + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @param string $identifier Person identifier + * @return void + */ + public function delete(string $coid, string $identifier): void + { + $People = $this->getPeopleTable(); + + $results = []; + $resultCode = 500; + + try { + $personId = $this->resolvePersonId((int)$coid, $identifier); + + $entity = $People->findById($personId)->firstOrFail(); + $People->deleteOrFail($entity); + + $resultCode = 200; + $results = []; + } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results = ['error' => $e->getMessage()]; + } catch (\Exception $e) { + $this->llog('debug', $e->getMessage()); + $resultCode = 500; + $results = ['error' => $e->getMessage()]; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Find a Person record scoped to CO, including all associations required to build a Person Profile message. + * + * @param int $coId + * @param int $personId + * @return EntityInterface + */ + protected function findPersonWithProfileContain(int $coId, int $personId): EntityInterface + { + $People = $this->getPeopleTable(); + $contain = $this->getPersonProfilesTable()->getPersonProfileContain(); + + return $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->contain($contain) + ->firstOrFail(); + } + + /** + * Obtain the PersonProfiles (configuration) table. + * + * @return \CoreApi\Model\Table\PersonProfilesTable + */ + protected function getPersonProfilesTable(): \CoreApi\Model\Table\PersonProfilesTable + { + /** @var \CoreApi\Model\Table\PersonProfilesTable $PersonProfiles */ + $PersonProfiles = TableRegistry::getTableLocator()->get('CoreApi.PersonProfiles'); + + return $PersonProfiles; + } + + /** + * Resolve the requested identifier to a Person ID within the requested CO. + * + * @since COmanage Registry v5.3.0 + * @param int $coId CO ID + * @param string $identifier Identifier as provided in the request URL + * @return int Person ID + */ + protected function resolvePersonId(int $coId, string $identifier): int + { + $People = $this->getPeopleTable(); + + if (ctype_digit($identifier)) { + $personId = (int)$identifier; + + // Verify CO scoping (don't allow cross-CO access via numeric IDs) + $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->firstOrFail(); + + return $personId; + } + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + // Preferred: login identifier within CO + try { + return (int)$Identifiers->lookupPersonByLogin($coId, $identifier); + } catch (\Exception $e) { + // fall through + } + + // Fallback: any identifier belonging to a person in this CO + $idRec = $Identifiers->find() + ->where([ + 'Identifiers.identifier' => $identifier, + 'Identifiers.person_id IS NOT NULL', + ]) + ->matching('People', function ($q) use ($coId) { + return $q->where(['People.co_id' => $coId]); + }) + ->firstOrFail(); + + return (int)$idRec->person_id; + } + + /** + * Obtain the People table. + * + * @since COmanage Registry v5.3.0 + * @return Table People table instance + */ + protected function getPeopleTable(): Table + { + return TableRegistry::getTableLocator()->get('People'); + } +} diff --git a/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php new file mode 100644 index 000000000..5133be063 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php @@ -0,0 +1,240 @@ + $record + * @param string $modelName + * @param array $extraSkipFields + * @return array + * @since COmanage Registry v5.3.0 + */ + public static function filterMetadataInbound(array $record, string $modelName, array $extraSkipFields = []): array + { + $ret = []; + + // Map the model to the changelog/foreign key style (eg Person -> person_id) + $mfk = Inflector::underscore($modelName) . '_id'; + + $skip = array_merge( + [ + // Changelog-ish / system metadata + 'actor_identifier', + 'created', + 'deleted', + 'id', + 'modified', + 'revision', + + // Common linkage keys in v5 + 'co_id', + 'person_id', + 'person_role_id', + 'external_identity_id', + 'external_identity_role_id', + 'group_id', + + // Source-ish / linkage keys present in v5 schema + 'source_external_identity_role_id', + 'external_identity_source_id', + 'api_user_id', + 'provisioning_target_id', + + $mfk, + ], + $extraSkipFields + ); + + foreach ($record as $k => $v) { + if ($k === 'meta') { + continue; + } + + // Skip related models (we only filter the current record here) + if (is_array($v)) { + continue; + } + + if (in_array((string)$k, $skip, true)) { + continue; + } + + $ret[(string)$k] = $v; + } + + if (!empty($record['meta']) && is_array($record['meta']) && array_key_exists('id', $record['meta'])) { + $ret['id'] = $record['meta']['id']; + } + + return $ret; + } + + /** + * Filter metadata on an outbound record (recursive). + * + * v5 behavior: + * - moves known metadata/system fields into meta + * - recurses into related models + * + * @param array $record + * @param string|null $modelName + * @param array $extraMetaFields + * @return array + * @since COmanage Registry v5.3.0 + */ + public static function filterMetadataOutbound(array $record, ?string $modelName = null, array $extraMetaFields = []): array + { + $ret = []; + + if (empty($record)) { + return $ret; + } + + foreach ($record as $m => $a) { + if (!is_array($a)) { + $ret[$m] = $a; + continue; + } + + $newa = []; + + $mfk = $modelName ? (Inflector::underscore($modelName) . '_id') : null; + + $metaFields = array_merge( + [ + // Changelog-ish / system metadata + 'actor_identifier', + 'created', + 'deleted', + 'id', + 'modified', + 'revision', + 'lft', + 'rght', + + // Common linkage keys in v5 + 'co_id', + 'person_id', + 'person_role_id', + 'external_identity_id', + 'external_identity_role_id', + 'group_id', + + // Additional linkage/config keys that are typically not considered "business fields" + 'api_user_id', + 'provisioning_target_id', + 'external_identity_source_id', + 'source_external_identity_role_id', + ], + $extraMetaFields + ); + + if ($mfk !== null) { + $metaFields[] = $mfk; + } + + foreach ($a as $k => $v) { + if (is_array($v)) { + // Related model + if (is_int($k)) { + // hasMany + $f = self::filterMetadataOutbound([$k => $v], (string)$m, $extraMetaFields); + $newa[$k] = $f[$k]; + } else { + // hasOne + $f = self::filterMetadataOutbound([$k => $v], (string)$k, $extraMetaFields); + $newa[$k] = $f[$k]; + } + continue; + } + + // Special-case: group_id generally treated as meta unless the model is GroupMember. + if (($modelName !== 'GroupMember' && $k === 'group_id') || in_array((string)$k, $metaFields, true)) { + $newa['meta'][(string)$k] = $v; + continue; + } + + // Parent keys implied by containment are skipped (MVPA ownership FKs in v5) + if (in_array((string)$k, ['person_id', 'person_role_id', 'external_identity_id', 'external_identity_role_id', 'group_id'], true)) { + continue; + } + + $newa[(string)$k] = $v; + } + + $ret[$m] = $newa; + } + + return $ret; + } + + /** + * Filter related models from an inbound record using a permitted-path regex whitelist. + * + * @param array $record + * @param array $permittedRegexes Array of regex strings (including delimiters) + * @return array + * @since COmanage Registry v5.3.0 + */ + public static function filterRelatedInbound(array $record, array $permittedRegexes): array + { + $flat = Hash::flatten($record); + + foreach (array_keys($flat) as $k) { + $ok = false; + + foreach ($permittedRegexes as $p) { + if (preg_match($p, (string)$k)) { + $ok = true; + break; + } + } + + if (!$ok) { + unset($flat[$k]); + } + } + + return Hash::expand($flat); + } +} diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php index 6ee9aec62..d25fdbd22 100644 --- a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -30,6 +30,7 @@ namespace CoreApi\Model\Table; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; use CoreApi\Lib\Enum\ResponseTypesEnum; @@ -51,7 +52,6 @@ class PersonProfilesTable extends Table * @since COmanage Registry v5.3.0 * @param array $config Configuration options passed to constructor */ - public function initialize(array $config): void { parent::initialize($config); @@ -64,13 +64,12 @@ public function initialize(array $config): void // Define associations $this->belongsTo('Apis'); - $this->belongsTo('ApiUsers'); $this->belongsTo('IdentifierTypes') ->setClassName('Types') ->setForeignKey('identifier_type_id') ->setProperty('identifier_type'); - $this->setDisplayField('api_user_id'); + $this->setDisplayField('api_id'); // This is an Entry Point Model under the Api (pluggable) config $this->setPrimaryLink('api_id'); @@ -78,14 +77,12 @@ public function initialize(array $config): void $this->setRedirectGoal('self'); $this->setEditContains([ - 'Apis', - 'ApiUsers', + 'Apis' => ['ApiUsers'], 'IdentifierTypes' ]); $this->setViewContains([ - 'Apis', - 'ApiUsers', + 'Apis' => ['ApiUsers'], 'IdentifierTypes' ]); @@ -93,10 +90,6 @@ public function initialize(array $config): void // - The column is identifier_type_id (FK -> types.id) // - The type selector should be constrained to the Identifiers.type type family $this->setAutoViewVars([ - 'apiUsers' => [ - 'type' => 'select', - 'model' => 'ApiUsers' - ], 'identifierTypes' => [ 'type' => 'type', 'attribute' => 'Identifiers.type' @@ -114,27 +107,131 @@ public function initialize(array $config): void $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ - 'delete' => false, // Delete the pluggable object instead + 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ - 'add' => false, + 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin'] ] ]); } + /** + * Build the contain graph for Person Profile reads from PeopleTable associations. + * + * This keeps the logic intentionally simple: + * - include all People hasOne/hasMany associations + * - for a few key associations, include their hasOne/hasMany associations as a second level + * - add minimal "glue" contain where needed (eg GroupMembers -> Groups) + * + * @return array + */ + public function getPersonProfileContain(): array + { + /** @var \App\Model\Table\PeopleTable $People */ + $People = TableRegistry::getTableLocator()->get('People'); + + $contain = $this->getOwnedAssociationNames($People); + + // Ensure PrimaryName is present (if configured as an association) + if (!in_array('PrimaryName', $contain, true) && $People->associations()->has('PrimaryName')) { + $contain[] = 'PrimaryName'; + } + + // GroupMembers is not very useful without the Group record + if (in_array('GroupMembers', $contain, true) && $People->associations()->has('GroupMembers')) { + $contain = $this->replaceContainEntry($contain, 'GroupMembers', ['Groups']); + } + + // Add one level of children for key "container" relations. + // IMPORTANT: $People->PersonRoles is an Association object, not a Table, + // so always resolve to the target table via associations()->get(...)->getTarget(). + if ($People->associations()->has('PersonRoles')) { + $personRolesTarget = $People->associations()->get('PersonRoles')->getTarget(); + + $contain = $this->replaceContainEntry( + $contain, + 'PersonRoles', + $this->getOwnedAssociationNames($personRolesTarget) + ); + } + + if ($People->associations()->has('ExternalIdentities')) { + $externalIdentitiesTarget = $People->associations()->get('ExternalIdentities')->getTarget(); + + $contain = $this->replaceContainEntry( + $contain, + 'ExternalIdentities', + $this->getOwnedAssociationNames($externalIdentitiesTarget) + ); + } + + return $contain; + } + + /** + * Return the names of hasMany/hasOne associations for a table. + * + * @param Table $table + * @return array + */ + protected function getOwnedAssociationNames(Table $table): array + { + $names = []; + + $associations = $table->associations()->getByType(['hasMany', 'hasOne']); + + foreach ($associations as $assoc) { + $names[] = $assoc->getName(); + } + + sort($names); + + return $names; + } + + /** + * Replace a top-level contain entry (eg "PersonRoles") with a nested contain definition. + * + * Works whether the entry is a numeric element ("PersonRoles") or already keyed. + * + * @param array $contain + * @param string $name + * @param array $nested + * @return array + */ + protected function replaceContainEntry(array $contain, string $name, array $nested): array + { + // remove numeric occurrence(s) + $out = []; + foreach ($contain as $k => $v) { + if (is_int($k) && $v === $name) { + continue; + } + if (is_string($k) && $k === $name) { + continue; + } + $out[$k] = $v; + } + + $out[$name] = $nested; + + return $out; + } + /** * Table specific logic to generate a display field. * * @since COmanage Registry v5.2.0 * @param \CoreApi\Model\Entity\PersonProfile $entity Entity to generate display field for - * @return string Display field + * @return string Display field */ - public function generateDisplayField(\CoreApi\Model\Entity\PersonProfile $entity): string { - return $entity->api->description; + public function generateDisplayField(\CoreApi\Model\Entity\PersonProfile $entity): string + { + return $entity->api->description; } /** @@ -142,9 +239,8 @@ public function generateDisplayField(\CoreApi\Model\Entity\PersonProfile $entity * * @since COmanage Registry v5.3.0 * @param Validator $validator Validator - * @return Validator Validator + * @return Validator Validator */ - public function validationDefault(Validator $validator): Validator { $validator->add('api_id', [ @@ -154,11 +250,6 @@ public function validationDefault(Validator $validator): Validator $this->registerStringValidation($validator, $this->getSchema(), 'status', true); - $validator->add('api_user_id', [ - 'content' => ['rule' => 'isInteger'] - ]); - $validator->notEmptyString('api_user_id'); - $validator->add('identifier_type_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/plugins/CoreApi/templates/PersonProfiles/fields.inc b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc index c66731ccf..9e87559ac 100644 --- a/app/plugins/CoreApi/templates/PersonProfiles/fields.inc +++ b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc @@ -37,7 +37,6 @@ if(!empty($vv_api_endpoint)) { // Fields for person_profiles table $fields = [ 'status', - 'api_user_id', 'identifier_type_id', 'index_response_type', 'expunge_on_delete' diff --git a/app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php b/app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php new file mode 100644 index 000000000..a344cbe59 --- /dev/null +++ b/app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php @@ -0,0 +1,379 @@ +get('Types'); + + try { + $label = (string)$Types->getTypeLabel($typeId); + } catch (\Exception $e) { + $label = null; + } + + if ($label !== null) { + $typeLabelCache[$typeId] = $label; + } + + return $label; +}; + +$formatMeta = function (object $entity): array { + $meta = []; + + if (isset($entity->id)) { + $meta['id'] = (int)$entity->id; + } + if (isset($entity->created)) { + $meta['created'] = $entity->created; + } + if (isset($entity->modified)) { + $meta['modified'] = $entity->modified; + } + if (isset($entity->deleted)) { + $meta['deleted'] = (bool)$entity->deleted; + } + if (isset($entity->revision)) { + $meta['revision'] = (int)$entity->revision; + } + if (isset($entity->actor_identifier)) { + $meta['actor_identifier'] = (string)$entity->actor_identifier; + } + + return $meta; +}; + +$formatPersonProfile = function (object $person) use ($formatMeta, $typeLabel): array { + $out = [ + 'Person' => [ + 'meta' => $formatMeta($person), + 'co_id' => (int)($person->co_id ?? 0), + 'date_of_birth' => $person->date_of_birth ?? null, + 'status' => (string)($person->status ?? ''), + 'timezone' => $person->timezone ?? null, + ], + ]; + + if (!empty($person->group_members)) { + $out['GroupMember'] = []; + foreach ($person->group_members as $gm) { + $out['GroupMember'][] = [ + 'meta' => $formatMeta($gm), + 'co_group_id' => (int)($gm->group_id ?? 0), + 'member' => isset($gm->member) ? (bool)$gm->member : null, + 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, + 'co_group_nesting_id' => $gm->group_nesting_id ?? null, + ]; + } + } + + if (!empty($person->email_addresses)) { + $out['EmailAddress'] = []; + foreach ($person->email_addresses as $ea) { + $out['EmailAddress'][] = [ + 'meta' => $formatMeta($ea), + 'mail' => $ea->mail ?? null, + 'type' => $typeLabel($ea->type_id ?? null), + 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, + ]; + } + } + + if (!empty($person->identifiers)) { + $out['Identifier'] = []; + foreach ($person->identifiers as $id) { + $out['Identifier'][] = [ + 'meta' => $formatMeta($id), + 'identifier' => $id->identifier ?? null, + 'login' => isset($id->login) ? (bool)$id->login : null, + 'status' => $id->status ?? null, + 'type' => $typeLabel($id->type_id ?? null), + ]; + } + } + + if (!empty($person->names)) { + $out['Name'] = []; + foreach ($person->names as $n) { + $out['Name'][] = [ + 'meta' => $formatMeta($n), + 'family' => $n->family ?? null, + 'formatted' => $n->display_name ?? null, + 'given' => $n->given ?? null, + 'language' => $n->language ?? null, + 'middle' => $n->middle ?? null, + 'prefix' => $n->honorific ?? null, + 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, + 'suffix' => $n->suffix ?? null, + 'type' => $typeLabel($n->type_id ?? null), + ]; + } + } + + if (!empty($person->urls)) { + $out['Url'] = []; + foreach ($person->urls as $u) { + $out['Url'][] = [ + 'meta' => $formatMeta($u), + 'description' => $u->description ?? null, + 'url' => $u->url ?? null, + 'type' => $typeLabel($u->type_id ?? null), + ]; + } + } + + if (!empty($person->person_roles)) { + $out['PersonRole'] = []; + foreach ($person->person_roles as $pr) { + $role = [ + 'meta' => $formatMeta($pr), + 'cou_id' => $pr->cou_id ?? null, + 'title' => $pr->title ?? null, + 'o' => $pr->organization ?? null, + 'ou' => $pr->department ?? null, + 'valid_from' => $pr->valid_from ?? null, + 'valid_through' => $pr->valid_through ?? null, + 'status' => $pr->status ?? null, + 'sponsor_person_id' => $pr->sponsor_person_id ?? null, + 'affiliation' => $pr->affiliation ?? null, + 'ordr' => $pr->ordr ?? null, + ]; + + if (!empty($pr->addresses)) { + $role['Address'] = []; + foreach ($pr->addresses as $a) { + $role['Address'][] = [ + 'meta' => $formatMeta($a), + 'country' => $a->country ?? null, + 'description' => $a->description ?? null, + 'language' => $a->language ?? null, + 'locality' => $a->locality ?? null, + 'postal_code' => $a->postal_code ?? null, + 'room' => $a->room ?? null, + 'state' => $a->state ?? null, + 'street' => $a->street ?? null, + 'type' => $typeLabel($a->type_id ?? null), + ]; + } + } + + if (!empty($pr->ad_hoc_attributes)) { + $role['AdHocAttribute'] = []; + foreach ($pr->ad_hoc_attributes as $aha) { + $role['AdHocAttribute'][] = [ + 'meta' => $formatMeta($aha), + 'tag' => $aha->tag ?? null, + 'value' => $aha->value ?? null, + ]; + } + } + + if (!empty($pr->telephone_numbers)) { + $role['TelephoneNumber'] = []; + foreach ($pr->telephone_numbers as $tn) { + $role['TelephoneNumber'][] = [ + 'meta' => $formatMeta($tn), + 'country_code' => $tn->country_code ?? null, + 'area_code' => $tn->area_code ?? null, + 'number' => $tn->number ?? null, + 'extension' => $tn->extension ?? null, + 'description' => $tn->description ?? null, + 'type' => $typeLabel($tn->type_id ?? null), + ]; + } + } + + $out['PersonRole'][] = $role; + } + } + + if (!empty($person->external_identities)) { + $out['ExternalIdentity'] = []; + foreach ($person->external_identities as $ei) { + $ext = [ + 'meta' => $formatMeta($ei), + 'co_id' => $ei->co_id ?? null, + 'title' => $ei->title ?? null, + 'o' => $ei->organization ?? null, + 'ou' => $ei->department ?? null, + 'valid_from' => $ei->valid_from ?? null, + 'valid_through' => $ei->valid_through ?? null, + 'status' => $ei->status ?? null, + 'affiliation' => $ei->affiliation ?? null, + 'date_of_birth' => $ei->date_of_birth ?? null, + ]; + + if (!empty($ei->addresses)) { + $ext['Address'] = []; + foreach ($ei->addresses as $a) { + $ext['Address'][] = [ + 'meta' => $formatMeta($a), + 'country' => $a->country ?? null, + 'description' => $a->description ?? null, + 'language' => $a->language ?? null, + 'locality' => $a->locality ?? null, + 'postal_code' => $a->postal_code ?? null, + 'room' => $a->room ?? null, + 'state' => $a->state ?? null, + 'street' => $a->street ?? null, + 'type' => $typeLabel($a->type_id ?? null), + ]; + } + } + + if (!empty($ei->ad_hoc_attributes)) { + $ext['AdHocAttribute'] = []; + foreach ($ei->ad_hoc_attributes as $aha) { + $ext['AdHocAttribute'][] = [ + 'meta' => $formatMeta($aha), + 'tag' => $aha->tag ?? null, + 'value' => $aha->value ?? null, + ]; + } + } + + if (!empty($ei->email_addresses)) { + $ext['EmailAddress'] = []; + foreach ($ei->email_addresses as $ea) { + $ext['EmailAddress'][] = [ + 'meta' => $formatMeta($ea), + 'mail' => $ea->mail ?? null, + 'type' => $typeLabel($ea->type_id ?? null), + 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, + ]; + } + } + + if (!empty($ei->identifiers)) { + $ext['Identifier'] = []; + foreach ($ei->identifiers as $id) { + $ext['Identifier'][] = [ + 'meta' => $formatMeta($id), + 'identifier' => $id->identifier ?? null, + 'login' => isset($id->login) ? (bool)$id->login : null, + 'status' => $id->status ?? null, + 'type' => $typeLabel($id->type_id ?? null), + ]; + } + } + + if (!empty($ei->names)) { + $ext['Name'] = []; + foreach ($ei->names as $n) { + $ext['Name'][] = [ + 'meta' => $formatMeta($n), + 'family' => $n->family ?? null, + 'formatted' => $n->display_name ?? null, + 'given' => $n->given ?? null, + 'language' => $n->language ?? null, + 'middle' => $n->middle ?? null, + 'prefix' => $n->honorific ?? null, + 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, + 'suffix' => $n->suffix ?? null, + 'type' => $typeLabel($n->type_id ?? null), + ]; + } + } + + if (!empty($ei->telephone_numbers)) { + $ext['TelephoneNumber'] = []; + foreach ($ei->telephone_numbers as $tn) { + $ext['TelephoneNumber'][] = [ + 'meta' => $formatMeta($tn), + 'country_code' => $tn->country_code ?? null, + 'area_code' => $tn->area_code ?? null, + 'number' => $tn->number ?? null, + 'extension' => $tn->extension ?? null, + 'description' => $tn->description ?? null, + 'type' => $typeLabel($tn->type_id ?? null), + ]; + } + } + + if (!empty($ei->urls)) { + $ext['Url'] = []; + foreach ($ei->urls as $u) { + $ext['Url'][] = [ + 'meta' => $formatMeta($u), + 'description' => $u->description ?? null, + 'url' => $u->url ?? null, + 'type' => $typeLabel($u->type_id ?? null), + ]; + } + } + + $out['ExternalIdentity'][] = $ext; + } + } + + return $out; +}; + +if (!empty($vv_people) && is_iterable($vv_people)) { + $page = (int)($vv_page ?? 1); + $limit = (int)($vv_limit ?? 100); + $total = (int)($vv_total ?? 0); + + $items = []; + foreach ($vv_people as $p) { + if ($p !== null) { + $items[] = $formatPersonProfile($p); + } + } + + $pageCount = ($limit > 0) ? (int)max(1, (int)ceil($total / $limit)) : 1; + $startIndex = ($total === 0) ? 0 : (($page - 1) * $limit) + 1; + + $out = []; + foreach (array_values($items) as $i => $item) { + $out[$i] = $item; + } + + $out['currentPage'] = (string)$page; + $out['itemsPerPage'] = (string)$limit; + $out['pageCount'] = (string)$pageCount; + $out['startIndex'] = (string)$startIndex; + $out['totalResults'] = (string)$total; + + $this->set('vv_results', $out); + $this->setResponse($this->getResponse()->withType('application/json')); + echo json_encode($out, JSON_UNESCAPED_SLASHES); + return; +} + +$person = $vv_person ?? null; +if ($person === null) { + $this->set('vv_results', ['error' => 'vv_person not set']); + return; +} + +$out = $formatPersonProfile($person); + +$this->set('vv_results', $out); + +$this->setResponse($this->getResponse()->withType('application/json')); +echo json_encode($out, JSON_UNESCAPED_SLASHES); +return; From e4fc47720a3191b714c2dfb984b7e2912419ba47 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 6 Jul 2026 10:51:52 +0000 Subject: [PATCH 04/16] Initial implementation of index. Start honoring plugin configurations during executions. Code improvements for read. --- .../CoreApi/config/person_profiles_routes.php | 10 +- app/plugins/CoreApi/config/plugin.json | 1 - .../PersonProfileApiV2Controller.php | 220 ++++++++++-------- .../src/Model/Table/PersonProfilesTable.php | 29 ++- .../json/person_profile.php | 7 +- .../json/person_profile_slim.php | 42 ++++ .../templates/PersonProfiles/fields.inc | 1 - app/src/Controller/StandardApiController.php | 22 +- 8 files changed, 217 insertions(+), 115 deletions(-) rename app/plugins/CoreApi/templates/{PersonProfiles => PersonProfileApiV2}/json/person_profile.php (97%) create mode 100644 app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php diff --git a/app/plugins/CoreApi/config/person_profiles_routes.php b/app/plugins/CoreApi/config/person_profiles_routes.php index 0657e545c..110777de2 100644 --- a/app/plugins/CoreApi/config/person_profiles_routes.php +++ b/app/plugins/CoreApi/config/person_profiles_routes.php @@ -37,7 +37,7 @@ // index (list) $builder->get( - '/{coid}/v2/person', + '/co/{coid}/v2/person', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'index'] ) ->setPass(['coid']) @@ -45,7 +45,7 @@ // create $builder->post( - '/{coid}/v2/person', + '/co/{coid}/v2/person', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'create'] ) ->setPass(['coid']) @@ -53,7 +53,7 @@ // read $builder->get( - '/{coid}/v2/person/{identifier}', + '/co/{coid}/v2/person/{identifier}', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'read'] ) ->setPass(['coid', 'identifier']) @@ -64,7 +64,7 @@ // update $builder->put( - '/{coid}/v2/person/{identifier}', + '/co/{coid}/v2/person/{identifier}', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'update'] ) ->setPass(['coid', 'identifier']) @@ -75,7 +75,7 @@ // delete $builder->delete( - '/{coid}/v2/person/{identifier}', + '/co/{coid}/v2/person/{identifier}', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'delete'] ) ->setPass(['coid', 'identifier']) diff --git a/app/plugins/CoreApi/config/plugin.json b/app/plugins/CoreApi/config/plugin.json index 15e69d47e..6d3b82dfe 100644 --- a/app/plugins/CoreApi/config/plugin.json +++ b/app/plugins/CoreApi/config/plugin.json @@ -25,7 +25,6 @@ "columns": { "id": {}, "api_id": {}, - "status": { "type": "string", "size": 2 }, "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, "index_response_type":{ "type": "string", "size": 2 }, "expunge_on_delete": { "type": "boolean" } diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index 5c31c2b87..5e7e7f8fe 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -30,16 +30,24 @@ namespace CoreApi\Controller; use App\Controller\StandardApiController; -use Cake\Datasource\EntityInterface; use Cake\Event\EventInterface; use Cake\Http\Exception\BadRequestException; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\View\View; +use CoreApi\Lib\Enum\ResponseTypesEnum; use CoreApi\Lib\Util\MessageFilter; class PersonProfileApiV2Controller extends StandardApiController { + /** + * Loaded PersonProfiles configuration for the current API (person_profiles row). + * + * @var object|null + * @since COmanage Registry v5.3.0 + */ + protected ?object $person_profile_cfg = null; + /** * Map the actions to the Entry Point Model that controls the configuration. * @@ -58,6 +66,40 @@ class PersonProfileApiV2Controller extends StandardApiController 'upsert' => 'PersonProfiles', ]; + /** + * beforeFilter callback. + * + * Loads the current plugin configuration (person_profiles) using the cached Apis row. + * + * @param EventInterface $event Cake Event + * @return void + * @since COmanage Registry v5.3.0 + */ + public function beforeFilter(EventInterface $event): void + { + parent::beforeFilter($event); + + // StandardApiController::calculatePermission() should have populated cur_api by now. + $api = $this->getApi(); + if (empty($api) || empty($api->id)) { + // Should not happen for authenticated requests; treat as bad request to avoid null derefs. + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $this->person_profile_cfg = $this->getPersonProfilesTable() + ->find() + ->where(['api_id' => (int)$api->id]) + ->contain(['IdentifierTypes']) + ->first(); + + if ($this->person_profile_cfg === null) { + // No configuration row exists for this API instance + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $this->set('vv_person_profile_cfg', $this->person_profile_cfg); + } + /** * Callback run prior to the request rendering. * @@ -67,20 +109,27 @@ class PersonProfileApiV2Controller extends StandardApiController */ public function beforeRender(EventInterface $event): void { - // If we set 'serialize', JsonView will skip templates entirely. - // For index/read we want the person_profile template to run. $action = (string)$this->request->getParam('action'); + $cfg = $this->person_profile_cfg; + - if (in_array($action, ['index', 'read'], true)) { - // Template will echo JSON, so do not use JsonView serialization. - $this->viewBuilder()->setOption('serialize', null); - $this->viewBuilder()->setClassName(View::class); - $this->viewBuilder()->setLayout(null); - $this->viewBuilder()->disableAutoLayout(); - } else { - // Keep current behavior for non-profile actions + // If the action is 'index' and the response type is 'Full', use the 'person_profile' template. + if ($action === 'index' && $cfg->index_response_type == ResponseTypesEnum::Full) { + $this->viewBuilder()->setOption('serialize', null) + ->setTemplate('person_profile'); + } // If the action is 'index' and the response type is 'IdentifierList', use the 'person_profile_slim' template. + elseif ($action === 'index' && $cfg->index_response_type == ResponseTypesEnum::IdentifierList) { + $this->viewBuilder()->setOption('serialize', null) + ->setTemplate('person_profile_slim'); + } // If the action is 'read', use the 'person_profile' template. + elseif ($action === 'read') { + $this->viewBuilder()->setOption('serialize', null) + ->setTemplate('person_profile'); + } // For all other non-profile actions, use JSON serialization with 'vv_results'. + else { $this->viewBuilder()->setOption('serialize', 'vv_results'); } + parent::beforeRender($event); } @@ -114,26 +163,26 @@ public function calculateRequestedCOID(): ?int public function index(string $coid): void { $People = $this->getPeopleTable(); + $Identifiers = $this->getIdentifiersTable(); try { $coId = (int)$coid; $identifier = $this->request->getQuery('identifier'); if (is_string($identifier) && $identifier !== '') { - $personId = $this->resolvePersonId($coId, $identifier); - $person = $this->findPersonWithProfileContain($coId, $personId); - - $this->viewBuilder() - ->setPlugin('CoreApi') - ->setTemplatePath('PersonProfiles/json') - ->setTemplate('person_profile'); + $typeId = $this->getConfiguredIdentifierTypeId(); + if ($typeId === null) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + $personId = $Identifiers->lookupPerson($typeId, $identifier); + $person = $this->getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); $this->set('vv_people', [$person]); $this->set('vv_page', 1); $this->set('vv_limit', 1); $this->set('vv_total', 1); - $this->response = $this->response->withStatus(200); + $this->response = $this->response->withStatus(200)->withType('application/json'); return; } @@ -157,7 +206,7 @@ public function index(string $coid): void } $baseQuery = $People->find()->where($conditions); - $total = (int)$baseQuery->count(); + $total = $baseQuery->count(); $contain = $this->getPersonProfilesTable()->getPersonProfileContain(); @@ -170,8 +219,6 @@ public function index(string $coid): void ->toList(); $this->viewBuilder() - ->setPlugin('CoreApi') - ->setTemplatePath('PersonProfiles/json') ->setTemplate('person_profile'); $this->set('vv_people', $people); @@ -179,15 +226,15 @@ public function index(string $coid): void $this->set('vv_limit', $limit); $this->set('vv_total', $total); - $this->response = $this->response->withStatus(200); + $this->response = $this->response->withStatus(200)->withType('application/json'); return; } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { - $this->response = $this->response->withStatus(404); + $this->response = $this->response->withStatus(404)->withType('application/json'); $this->set('vv_results', ['error' => $e->getMessage()]); return; } catch (\Exception $e) { $this->llog('debug', $e->getMessage()); - $this->response = $this->response->withStatus(500); + $this->response = $this->response->withStatus(500)->withType('application/json'); $this->set('vv_results', ['error' => $e->getMessage()]); return; } @@ -219,26 +266,29 @@ public function create(string $coid): void */ public function read(string $coid, string $identifier): void { + $Identifiers = $this->getIdentifiersTable(); + try { $coId = (int)$coid; - $personId = $this->resolvePersonId($coId, $identifier); - $person = $this->findPersonWithProfileContain($coId, $personId); - $this->viewBuilder() - ->setPlugin('CoreApi') - ->setTemplatePath('PersonProfiles/json') - ->setTemplate('person_profile'); + $typeId = $this->getConfiguredIdentifierTypeId(); + if ($typeId === null) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $personId = $Identifiers->lookupPerson($typeId, $identifier); + $person = $this->getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); $this->set('vv_person', $person); - $this->response = $this->response->withStatus(200); + $this->response = $this->response->withStatus(200)->withType('application/json'); return; } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { - $this->response = $this->response->withStatus(404); + $this->response = $this->response->withStatus(404)->withType('application/json'); $this->set('vv_results', ['error' => $e->getMessage()]); return; } catch (\Exception $e) { $this->llog('debug', $e->getMessage()); - $this->response = $this->response->withStatus(500); + $this->response = $this->response->withStatus(500)->withType('application/json'); $this->set('vv_results', ['error' => $e->getMessage()]); return; } @@ -299,6 +349,7 @@ public function put(string $coid, string $identifier): void public function upsert(string $coid, ?string $identifier = null): void { $People = $this->getPeopleTable(); + $Identifiers = $this->getIdentifiersTable(); $payload = $this->request->getData(); if (empty($payload) || !is_array($payload)) { @@ -328,7 +379,12 @@ public function upsert(string $coid, ?string $identifier = null): void $resultCode = 201; $results = ['id' => $entity->id]; } else { - $personId = $this->resolvePersonId((int)$coid, $identifier); + $typeId = $this->getConfiguredIdentifierTypeId(); + if ($typeId === null) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $personId = $Identifiers->lookupPerson($typeId, $identifier); $entity = $People->findById($personId)->firstOrFail(); $entity = $People->patchEntity($entity, $personData); @@ -362,12 +418,18 @@ public function upsert(string $coid, ?string $identifier = null): void public function delete(string $coid, string $identifier): void { $People = $this->getPeopleTable(); + $Identifiers = $this->getIdentifiersTable(); $results = []; $resultCode = 500; try { - $personId = $this->resolvePersonId((int)$coid, $identifier); + $typeId = $this->getConfiguredIdentifierTypeId(); + if ($typeId === null) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $personId = $Identifiers->lookupPerson($typeId, $identifier); $entity = $People->findById($personId)->firstOrFail(); $People->deleteOrFail($entity); @@ -387,24 +449,6 @@ public function delete(string $coid, string $identifier): void $this->set('vv_results', $results); } - /** - * Find a Person record scoped to CO, including all associations required to build a Person Profile message. - * - * @param int $coId - * @param int $personId - * @return EntityInterface - */ - protected function findPersonWithProfileContain(int $coId, int $personId): EntityInterface - { - $People = $this->getPeopleTable(); - $contain = $this->getPersonProfilesTable()->getPersonProfileContain(); - - return $People->find() - ->where(['People.id' => $personId, 'People.co_id' => $coId]) - ->contain($contain) - ->firstOrFail(); - } - /** * Obtain the PersonProfiles (configuration) table. * @@ -419,59 +463,43 @@ protected function getPersonProfilesTable(): \CoreApi\Model\Table\PersonProfiles } /** - * Resolve the requested identifier to a Person ID within the requested CO. + * Obtain the People table. * * @since COmanage Registry v5.3.0 - * @param int $coId CO ID - * @param string $identifier Identifier as provided in the request URL - * @return int Person ID + * @return Table People table instance */ - protected function resolvePersonId(int $coId, string $identifier): int + protected function getPeopleTable(): Table { - $People = $this->getPeopleTable(); - - if (ctype_digit($identifier)) { - $personId = (int)$identifier; - - // Verify CO scoping (don't allow cross-CO access via numeric IDs) - $People->find() - ->where(['People.id' => $personId, 'People.co_id' => $coId]) - ->firstOrFail(); - - return $personId; - } - - $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); - - // Preferred: login identifier within CO - try { - return (int)$Identifiers->lookupPersonByLogin($coId, $identifier); - } catch (\Exception $e) { - // fall through - } - - // Fallback: any identifier belonging to a person in this CO - $idRec = $Identifiers->find() - ->where([ - 'Identifiers.identifier' => $identifier, - 'Identifiers.person_id IS NOT NULL', - ]) - ->matching('People', function ($q) use ($coId) { - return $q->where(['People.co_id' => $coId]); - }) - ->firstOrFail(); - - return (int)$idRec->person_id; + return TableRegistry::getTableLocator()->get('People'); } /** - * Obtain the People table. + * Obtain the Identifiers table. * * @since COmanage Registry v5.3.0 * @return Table People table instance */ - protected function getPeopleTable(): Table + protected function getIdentifiersTable(): Table { - return TableRegistry::getTableLocator()->get('People'); + return TableRegistry::getTableLocator()->get('Identifiers'); + } + + /** + * Return the configured identifier type id (or null if not configured). + * + * @return int|null + * @since COmanage Registry v5.3.0 + */ + protected function getConfiguredIdentifierTypeId(): ?int + { + $cfg = $this->person_profile_cfg; + + if ($cfg === null) { + return null; + } + + $typeId = $cfg->identifier_type_id ?? null; + + return ($typeId !== null) ? (int)$typeId : null; } } diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php index d25fdbd22..5e8a8c364 100644 --- a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -29,6 +29,7 @@ namespace CoreApi\Model\Table; +use Cake\Datasource\EntityInterface; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Validation\Validator; @@ -94,10 +95,6 @@ public function initialize(array $config): void 'type' => 'type', 'attribute' => 'Identifiers.type' ], - 'statuses' => [ - 'type' => 'enum', - 'class' => 'SuspendableStatusEnum' - ], 'indexResponseTypes' => [ 'type' => 'enum', 'class' => 'CoreApi.ResponseTypesEnum' @@ -119,6 +116,27 @@ public function initialize(array $config): void ]); } + /** + * Find a Person record scoped to CO, including all associations required to build a Person Profile message. + * + * @param int $coId + * @param int $personId + * @return EntityInterface + * @since COmanage Registry v5.3.0 + */ + public function findPersonWithProfileContain(int $coId, int $personId): EntityInterface + { + /** @var \App\Model\Table\PeopleTable $People */ + $People = TableRegistry::getTableLocator()->get('People'); + + $contain = $this->getPersonProfileContain(); + + return $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->contain($contain) + ->firstOrFail(); + } + /** * Build the contain graph for Person Profile reads from PeopleTable associations. * @@ -128,6 +146,7 @@ public function initialize(array $config): void * - add minimal "glue" contain where needed (eg GroupMembers -> Groups) * * @return array + * @since COmanage Registry v5.3.0 */ public function getPersonProfileContain(): array { @@ -248,8 +267,6 @@ public function validationDefault(Validator $validator): Validator ]); $validator->notEmptyString('api_id'); - $this->registerStringValidation($validator, $this->getSchema(), 'status', true); - $validator->add('identifier_type_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php similarity index 97% rename from app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php rename to app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php index a344cbe59..bae7cb650 100644 --- a/app/plugins/CoreApi/templates/PersonProfiles/json/person_profile.php +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php @@ -82,10 +82,10 @@ foreach ($person->group_members as $gm) { $out['GroupMember'][] = [ 'meta' => $formatMeta($gm), - 'co_group_id' => (int)($gm->group_id ?? 0), + 'group_id' => (int)($gm->group_id ?? 0), 'member' => isset($gm->member) ? (bool)$gm->member : null, 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, - 'co_group_nesting_id' => $gm->group_nesting_id ?? null, + 'group_nesting_id' => $gm->group_nesting_id ?? null, ]; } } @@ -359,7 +359,6 @@ $out['totalResults'] = (string)$total; $this->set('vv_results', $out); - $this->setResponse($this->getResponse()->withType('application/json')); echo json_encode($out, JSON_UNESCAPED_SLASHES); return; } @@ -373,7 +372,5 @@ $out = $formatPersonProfile($person); $this->set('vv_results', $out); - -$this->setResponse($this->getResponse()->withType('application/json')); echo json_encode($out, JSON_UNESCAPED_SLASHES); return; diff --git a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php new file mode 100644 index 000000000..15b222c7d --- /dev/null +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php @@ -0,0 +1,42 @@ +identifiers)) { + foreach ($person->identifiers as $id) { + if($vv_person_profile_cfg->identifier_type_id == $id->type_id) { + $items[] = $id->identifier ?? null; + } + } + } + } + + $pageCount = ($limit > 0) ? (int)max(1, (int)ceil($total / $limit)) : 1; + $startIndex = ($total === 0) ? 0 : (($page - 1) * $limit) + 1; + + $out = []; + foreach (array_values($items) as $i => $item) { + $out[$i] = $item; + } + + $out['currentPage'] = (string)$page; + $out['itemsPerPage'] = (string)$limit; + $out['pageCount'] = (string)$pageCount; + $out['startIndex'] = (string)$startIndex; + $out['totalResults'] = (string)$total; + + $this->set('vv_results', $out); + echo json_encode($out, JSON_UNESCAPED_SLASHES); + return; +} + +return; \ No newline at end of file diff --git a/app/plugins/CoreApi/templates/PersonProfiles/fields.inc b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc index 9e87559ac..92cdb1200 100644 --- a/app/plugins/CoreApi/templates/PersonProfiles/fields.inc +++ b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc @@ -36,7 +36,6 @@ if(!empty($vv_api_endpoint)) { // Fields for person_profiles table $fields = [ - 'status', 'identifier_type_id', 'index_response_type', 'expunge_on_delete' diff --git a/app/src/Controller/StandardApiController.php b/app/src/Controller/StandardApiController.php index be615572e..99bda029b 100644 --- a/app/src/Controller/StandardApiController.php +++ b/app/src/Controller/StandardApiController.php @@ -34,6 +34,14 @@ use App\Lib\Enum\SuspendableStatusEnum; class StandardApiController extends AppController { + /** + * Cached API configuration for the current request (Apis row). + * + * @var object|null + * @since COmanage Registry v5.3.0 + */ + private ?object $cur_api = null; + /** * Perform Cake Controller initialization. * @@ -103,17 +111,29 @@ public function calculatePermission(): bool { ]) ->firstOrFail(); - // We manually check status (as opposed to updating the find) to faciliate logging + // We manually check status (as opposed to updating the find to facilitate logging if($api->status != SuspendableStatusEnum::Active) { throw new \InvalidArgumentException("API " . $api->id . " is not active"); } + + // Cache the API configuration so controllers can access it during this request + $this->cur_api = $api; // If we get here the API User is authorized for the requested plugin configuration return true; } + /** + * Retrieve the current API instance. + * + * @return object The current API object. + */ + public function getApi(): object { + return $this->cur_api; + } + /** * Indicate whether this Controller will handle some or all authnz. * From f440218c21259067a9571b8346fc2283ac6f099c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 7 Jul 2026 03:01:18 +0000 Subject: [PATCH 05/16] Added index native pagination.Added query parameter parsing and validation. --- .../CoreApi/config/person_profiles_routes.php | 39 +- .../PersonProfileApiV2Controller.php | 74 ++-- .../src/Lib/Traits/ApiPaginationTrait.php | 173 ++++++++ .../CoreApiQueryParamsMiddleware.php | 372 ++++++++++++++++++ 4 files changed, 631 insertions(+), 27 deletions(-) create mode 100644 app/plugins/CoreApi/src/Lib/Traits/ApiPaginationTrait.php create mode 100644 app/plugins/CoreApi/src/Middleware/CoreApiQueryParamsMiddleware.php diff --git a/app/plugins/CoreApi/config/person_profiles_routes.php b/app/plugins/CoreApi/config/person_profiles_routes.php index 110777de2..cfdb258c3 100644 --- a/app/plugins/CoreApi/config/person_profiles_routes.php +++ b/app/plugins/CoreApi/config/person_profiles_routes.php @@ -29,13 +29,34 @@ use Cake\Routing\RouteBuilder; // Person Profiles API v2 -// Base: /api/person-profiles/{coid}/v2/person +// Base: /api/person-profiles/co/{coid}/v2/person(.json) +// +// Query parameters supported on the index endpoint (GET): +// - limit: integer 1..1001 (example: limit=25) +// - page: integer >= 1 (example: page=2) +// - direction: asc|desc (example: direction=desc) +// - identifier: string (returns a single resolved person profile in a 1-item envelope) +// - People.status OR person_status (depending on query-param middleware / compatibility layer) +// +// Notes: +// - Route patterns (setPatterns) validate PATH segments only (eg {coid}, {identifier} in the URL path), +// not query string parameters. +// - The read/update/delete endpoints use the {identifier} PATH segment, not the identifier query param. +// - POST/PUT request bodies are JSON (BodyParserMiddleware enabled for this scope). $routes->scope('/api/person-profiles', function (RouteBuilder $builder) { $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); $builder->setExtensions(['json']); $builder->applyMiddleware('bodyparser'); // index (list) + // Examples (simple -> complex): + // 1) List people (defaults): GET /api/person-profiles/co/2/v2/person.json + // 2) Page/limit: GET /api/person-profiles/co/2/v2/person.json?limit=25&page=1 + // 3) Sort direction: GET /api/person-profiles/co/2/v2/person.json?direction=desc&limit=25&page=2 + // 4) Filter by status (internal field): GET /api/person-profiles/co/2/v2/person.json?People.status=A&limit=50&page=1&direction=asc + // 5) Filter by status (preferred param): GET /api/person-profiles/co/2/v2/person.json?person_status=active&limit=50&page=1&direction=asc + // 6) Resolve one by identifier: GET /api/person-profiles/co/2/v2/person.json?identifier=C00000001 + // 7) Resolve + paging params (ignored): GET /api/person-profiles/co/2/v2/person.json?identifier=C00000001&limit=10&page=3&direction=desc $builder->get( '/co/{coid}/v2/person', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'index'] @@ -44,6 +65,10 @@ ->setPatterns(['coid' => '[0-9]+']); // create + // Examples (simple -> complex): + // 1) Create minimal (JSON body): POST /api/person-profiles/co/2/v2/person.json + // 2) Create with attributes (JSON body): POST /api/person-profiles/co/2/v2/person.json + // Note: query params are not used by this route; send fields in the JSON body. $builder->post( '/co/{coid}/v2/person', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'create'] @@ -52,6 +77,10 @@ ->setPatterns(['coid' => '[0-9]+']); // read + // Examples (simple -> complex): + // 1) Read by identifier: GET /api/person-profiles/co/2/v2/person/C00000001.json + // 2) Read by other identifier string: GET /api/person-profiles/co/2/v2/person/jdoe@example.org.json + // Note: query parameters are not used by this route. $builder->get( '/co/{coid}/v2/person/{identifier}', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'read'] @@ -63,6 +92,10 @@ ]); // update + // Examples (simple -> complex): + // 1) Update minimal (JSON body): PUT /api/person-profiles/co/2/v2/person/C00000001.json + // 2) Update with more fields (JSON body): PUT /api/person-profiles/co/2/v2/person/C00000001.json + // Note: query parameters are not used by this route; send fields in the JSON body. $builder->put( '/co/{coid}/v2/person/{identifier}', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'update'] @@ -74,6 +107,10 @@ ]); // delete + // Examples (simple -> complex): + // 1) Delete by identifier: DELETE /api/person-profiles/co/2/v2/person/C00000001.json + // 2) Delete by other identifier string: DELETE /api/person-profiles/co/2/v2/person/jdoe@example.org.json + // Note: query parameters are not used by this route. $builder->delete( '/co/{coid}/v2/person/{identifier}', ['plugin' => 'CoreApi', 'controller' => 'PersonProfileApiV2', 'action' => 'delete'] diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index 5e7e7f8fe..6e57f553b 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -36,10 +36,13 @@ use Cake\ORM\TableRegistry; use Cake\View\View; use CoreApi\Lib\Enum\ResponseTypesEnum; +use CoreApi\Lib\Traits\ApiPaginationTrait; use CoreApi\Lib\Util\MessageFilter; class PersonProfileApiV2Controller extends StandardApiController { + use ApiPaginationTrait; + /** * Loaded PersonProfiles configuration for the current API (person_profiles row). * @@ -156,6 +159,8 @@ public function calculateRequestedCOID(): ?int * - page: page number (1-based) * - People.status: optional filter * + * Uses CakePHP pagination for the general list case. + * * @since COmanage Registry v5.3.0 * @param string $coid CO ID * @return void @@ -168,12 +173,14 @@ public function index(string $coid): void try { $coId = (int)$coid; + // Special case: if identifier is provided, return exactly one match (enveloped like a page) $identifier = $this->request->getQuery('identifier'); if (is_string($identifier) && $identifier !== '') { $typeId = $this->getConfiguredIdentifierTypeId(); if ($typeId === null) { throw new BadRequestException(__d('error', 'invalid.request')); } + $personId = $Identifiers->lookupPerson($typeId, $identifier); $person = $this->getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); @@ -186,44 +193,55 @@ public function index(string $coid): void return; } - $direction = strtolower((string)$this->request->getQuery('direction', 'asc')); - $direction = ($direction === 'desc') ? 'DESC' : 'ASC'; - - $limit = (int)$this->request->getQuery('limit', 100); - if ($limit < 1) { - $limit = 100; - } + $pagination = $this->getPaginationParams( + request: $this->request, + defaultLimit: 100, + defaultPage: 1, + defaultDirection: 'ASC' + ); - $page = (int)$this->request->getQuery('page', 1); - if ($page < 1) { - $page = 1; - } + // Build base conditions + $conditions = ['People.co_id' => $coId]; $statusFilter = $this->request->getQuery('People.status'); - $conditions = ['People.co_id' => $coId]; if (is_string($statusFilter) && $statusFilter !== '') { $conditions['People.status'] = $statusFilter; } - $baseQuery = $People->find()->where($conditions); - $total = $baseQuery->count(); - + // Build query + contain graph $contain = $this->getPersonProfilesTable()->getPersonProfileContain(); - $people = $baseQuery - ->orderBy(['People.id' => $direction]) - ->limit($limit) - ->offset(($page - 1) * $limit) - ->contain($contain) - ->all() - ->toList(); + $baseQuery = $People->find() + ->where($conditions) + ->contain($contain); - $this->viewBuilder() - ->setTemplate('person_profile'); + $paginateConfig = $this->buildPaginateConfig( + modelAlias: 'People', + orderField: 'id', + direction: $pagination['direction'], + maxLimit: 1001 + ); + + $paginated = $this->paginate($baseQuery, $paginateConfig); + + $people = $paginated->toList(); + + $applied = $this->getAppliedPaging( + request: $this->request, + modelAlias: 'People', + fallbackPage: $pagination['page'], + fallbackLimit: $pagination['limit'] + ); + + // Total count comes from Cake's paging metadata (not from a ResultSet method) + $paging = $this->request->getAttribute('paging') ?? []; + $total = (int)($paging['People']['count'] ?? 0); + + $this->viewBuilder()->setTemplate('person_profile'); $this->set('vv_people', $people); - $this->set('vv_page', $page); - $this->set('vv_limit', $limit); + $this->set('vv_page', $applied['page']); + $this->set('vv_limit', $applied['limit']); $this->set('vv_total', $total); $this->response = $this->response->withStatus(200)->withType('application/json'); @@ -232,6 +250,10 @@ public function index(string $coid): void $this->response = $this->response->withStatus(404)->withType('application/json'); $this->set('vv_results', ['error' => $e->getMessage()]); return; + } catch (\Cake\Http\Exception\BadRequestException $e) { + $this->response = $this->response->withStatus(400)->withType('application/json'); + $this->set('vv_results', ['error' => $e->getMessage()]); + return; } catch (\Exception $e) { $this->llog('debug', $e->getMessage()); $this->response = $this->response->withStatus(500)->withType('application/json'); diff --git a/app/plugins/CoreApi/src/Lib/Traits/ApiPaginationTrait.php b/app/plugins/CoreApi/src/Lib/Traits/ApiPaginationTrait.php new file mode 100644 index 000000000..3173a1cac --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Traits/ApiPaginationTrait.php @@ -0,0 +1,173 @@ +paginate($query, $config). + * + * @since COmanage Registry v5.3.0 + */ +trait ApiPaginationTrait +{ + /** + * Normalize page/limit/direction from the request query string. + * + * Expected query parameters: + * - limit: positive integer + * - page: positive integer (1-based) + * - direction: asc|desc (case-insensitive), defaults to asc + * + * Note: If you have query-param middleware, it should already have validated these, + * but this method defensively normalizes them anyway. + * + * @param ServerRequest $request Request instance + * @param int $defaultLimit Default limit when missing/invalid + * @param int $defaultPage Default page when missing/invalid + * @param string $defaultDirection Default direction ('ASC'|'DESC') + * @return array{limit:int,page:int,direction:string} Normalized pagination params + * @since COmanage Registry v5.3.0 + */ + protected function getPaginationParams( + ServerRequest $request, + int $defaultLimit = 100, + int $defaultPage = 1, + string $defaultDirection = 'ASC' + ): array { + $limit = (int)$request->getQuery('limit', $defaultLimit); + if ($limit < 1) { + $limit = $defaultLimit; + } + + $page = (int)$request->getQuery('page', $defaultPage); + if ($page < 1) { + $page = $defaultPage; + } + + $dir = strtolower((string)$request->getQuery('direction', $defaultDirection)); + $direction = ($dir === 'desc') ? 'DESC' : 'ASC'; + + return [ + 'limit' => $limit, + 'page' => $page, + 'direction' => $direction, + ]; + } + + /** + * Build a CakePHP paginate() configuration array. + * + * Typical usage: + * $params = $this->getPaginationParams($this->request); + * $cfg = $this->buildPaginateConfig( + * modelAlias: 'People', + * orderField: 'id', + * direction: $params['direction'], + * maxLimit: 1001 + * ); + * $resultSet = $this->paginate($query, $cfg); + * + * Notes: + * - Cake's paginator will read ?page and ?limit automatically. + * - maxLimit is enforced regardless of user input. + * + * @param string $modelAlias ORM alias used in the order clause (eg 'People') + * @param string $orderField Field name to order by (eg 'id') + * @param string $direction 'ASC'|'DESC' + * @param int $maxLimit Maximum allowed limit + * @return array Paginate configuration for Controller::paginate() + * @since COmanage Registry v5.3.0 + */ + protected function buildPaginateConfig( + string $modelAlias, + string $orderField, + string $direction = 'ASC', + int $maxLimit = 1001 + ): array { + $direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC'; + + return [ + 'order' => [$modelAlias . '.' . $orderField => $direction], + 'maxLimit' => $maxLimit, + ]; + } + + /** + * Extract the page and perPage values Cake actually applied for a model alias. + * + * Cake writes paging metadata into a request attribute named "paging". + * This method reads it and returns a stable shape you can pass to your templates. + * + * @param ServerRequest $request Request instance (after paginate() has been called) + * @param string $modelAlias ORM alias used for pagination (eg 'People') + * @param int $fallbackPage Fallback when paging metadata is missing + * @param int $fallbackLimit Fallback when paging metadata is missing + * @return array{page:int,limit:int} Applied values + * @since COmanage Registry v5.3.0 + */ + protected function getAppliedPaging( + ServerRequest $request, + string $modelAlias, + int $fallbackPage = 1, + int $fallbackLimit = 100 + ): array { + $paging = $request->getAttribute('paging'); + + if (!is_array($paging) || empty($paging[$modelAlias]) || !is_array($paging[$modelAlias])) { + return [ + 'page' => $fallbackPage, + 'limit' => $fallbackLimit, + ]; + } + + $page = (int)($paging[$modelAlias]['page'] ?? $fallbackPage); + $limit = (int)($paging[$modelAlias]['perPage'] ?? $fallbackLimit); + + if ($page < 1) { + $page = $fallbackPage; + } + if ($limit < 1) { + $limit = $fallbackLimit; + } + + return [ + 'page' => $page, + 'limit' => $limit, + ]; + } +} diff --git a/app/plugins/CoreApi/src/Middleware/CoreApiQueryParamsMiddleware.php b/app/plugins/CoreApi/src/Middleware/CoreApiQueryParamsMiddleware.php new file mode 100644 index 000000000..77e1c2c13 --- /dev/null +++ b/app/plugins/CoreApi/src/Middleware/CoreApiQueryParamsMiddleware.php @@ -0,0 +1,372 @@ + ['integer' => ['range' => [1, 1001]]] + * - 'direction' => ['string' => ['inList' => [['asc','desc']]]] + * - 'identifier' => ['string' => []] // no rule + * + * @var array>|array>> + * @since COmanage Registry v5.3.0 + */ + protected array $allowedQueryParams = [ + 'limit' => ['integer' => ['range' => [1, 1001]]], + 'direction' => ['string' => ['inList' => [['asc', 'desc']]]], + 'page' => ['integer' => ['comparison' => ['>=', 1]]], + 'person_status' => ['string' => ['customRegex' => ['regex' => '/^[A-Za-z]{1,10}$/']]], + 'organization_type' => ['string' => ['customRegex' => ['regex' => '/^[A-Za-z]{1,10}$/']]], + 'identifier' => ['string' => []], + ]; + + /** + * Map request query params to internal "Model.field" names and (optionally) enum classes. + * + * If enumClass is provided, the middleware will map request value "active" to + * EnumClass::Active and replace the query param value with the enum constant value. + * + * @var array + * @since COmanage Registry v5.3.0 + */ + protected array $queryParamTransformMap = [ + // v4 style -> v5 internal + 'person_status' => [ + 'field' => 'People.status', + 'enumClass' => \App\Lib\Enum\StatusEnum::class, + ], + // If you later add an OrganizationTypeEnum, you can wire it here similarly. + // 'organization_type' => [ + // 'field' => 'Organizations.type', + // 'enumClass' => \App\Lib\Enum\OrganizationTypeEnum::class, + // ], + ]; + + /** + * Whether to transform model_field parameters (eg person_status) + * into Model.field and enum constant values. + * + * If enabled, a parameter like: + * - person_status=active + * becomes: + * - CoPerson.status = StatusEnum::Active (resolved via constant()) + * + * If the enum constant cannot be resolved, the parameter is dropped. + * + * @var bool + * @since COmanage Registry v5.3.0 + */ + protected bool $transformModelFieldToEnum; + + /** + * Constructor. + * + * @param bool $transformModelFieldToEnum Whether to transform model_field params into Model.field enum values + * @since COmanage Registry v5.3.0 + */ + public function __construct(bool $transformModelFieldToEnum = true) + { + $this->transformModelFieldToEnum = $transformModelFieldToEnum; + } + + /** + * Process an incoming server request. + * + * @param ServerRequestInterface $request PSR-7 request + * @param RequestHandlerInterface $handler PSR-15 request handler + * @return ResponseInterface PSR-7 response + * @since COmanage Registry v5.3.0 + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $queryParams = $request->getQueryParams(); + + if (!empty($queryParams)) { + $queryParams = $this->validateQueryParams($queryParams); + + if ($this->transformModelFieldToEnum) { + $queryParams = $this->parseQueryParams($queryParams); + } + + $request = $request->withQueryParams($queryParams); + } + + return $handler->handle($request); + } + + /** + * Validate and normalize query parameters based on the allowlist. + * + * Behavior: + * - Unknown params are removed + * - Allowed params are type-cast (integer/string) + * - Validation rules are applied; invalid params are removed + * + * @param array $queryParams Raw query parameters + * @return array Sanitized query parameters + * @since COmanage Registry v5.3.0 + */ + protected function validateQueryParams(array $queryParams): array + { + if (empty($queryParams)) { + return []; + } + + $allowedNames = array_keys($this->allowedQueryParams); + + foreach (array_keys($queryParams) as $key) { + if (!in_array($key, $allowedNames, true)) { + unset($queryParams[$key]); + } + } + + foreach ($this->allowedQueryParams as $param => $validationRule) { + if (!array_key_exists($param, $queryParams) || $queryParams[$param] === null || $queryParams[$param] === '') { + continue; + } + + $type = (string)key($validationRule); + + if (!$this->castType($queryParams, $param, $type)) { + unset($queryParams[$param]); + continue; + } + + $rulesForType = $validationRule[$type] ?? []; + + if (empty($rulesForType)) { + continue; + } + + $ruleName = (string)key($rulesForType); + + if ($ruleName === '') { + continue; + } + + if (!$this->applyRule($ruleName, $queryParams[$param], $rulesForType[$ruleName])) { + unset($queryParams[$param]); + } + } + + return $queryParams; + } + + /** + * Attempt to cast a query parameter to a given type. + * + * @param array $queryParams Query parameters (modified in-place) + * @param string $param Parameter name + * @param string $type Target type ('integer'|'string' supported) + * @return bool True on success, false on failure + * @since COmanage Registry v5.3.0 + */ + protected function castType(array &$queryParams, string $param, string $type): bool + { + $value = $queryParams[$param]; + + switch ($type) { + case 'integer': + if (is_int($value)) { + return true; + } + + if (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) { + $queryParams[$param] = (int)$value; + return true; + } + + return false; + + case 'string': + if (is_scalar($value)) { + $queryParams[$param] = (string)$value; + return true; + } + + return false; + + default: + return false; + } + } + + /** + * Apply a validation rule to a value. + * + * Supported rules: + * - range (Cake Validation) + * - inList (Cake Validation) + * - comparison (Cake Validation) + * - customRegex (preg_match) + * + * @param string $ruleName Rule name + * @param mixed $value Value to validate + * @param mixed $options Rule options (varies by rule) + * @return bool True if valid, false otherwise + * @since COmanage Registry v5.3.0 + */ + protected function applyRule(string $ruleName, mixed $value, mixed $options): bool + { + switch ($ruleName) { + case 'range': + if (!is_array($options) || count($options) < 2) { + return false; + } + return Validation::range($value, $options[0], $options[1]); + + case 'inList': + if (!is_array($options) || empty($options[0]) || !is_array($options[0])) { + return false; + } + return Validation::inList($value, $options[0]); + + case 'comparison': + if (!is_array($options) || count($options) < 2) { + return false; + } + return Validation::comparison($value, $options[0], $options[1]); + + case 'customRegex': + if (!is_array($options) || empty($options['regex']) || !is_string($options['regex'])) { + return false; + } + return (bool)preg_match($options['regex'], (string)$value); + + default: + return false; + } + } + + /** + * Parse query parameters into internal field names, and map values to enum constants where configured. + * + * Notes: + * - This does NOT attempt to guess enum classes from field names. + * - Only parameters listed in $queryParamTransformMap are transformed. + * - Unmapped parameters (limit/page/direction/identifier) pass through unchanged. + * + * @param array $queryParams Validated query parameters + * @return array Parsed query parameters + * @since COmanage Registry v5.3.0 + */ + protected function parseQueryParams(array $queryParams): array + { + if (empty($queryParams)) { + return $queryParams; + } + + $out = []; + + foreach ($queryParams as $attr => $reqValue) { + if (!isset($this->queryParamTransformMap[$attr])) { + $out[$attr] = $reqValue; + continue; + } + + $map = $this->queryParamTransformMap[$attr]; + $targetField = $map['field']; + + if (isset($map['enumClass'])) { + $enumValue = $this->resolveEnumValue($map['enumClass'], $reqValue); + + if ($enumValue === null) { + // If we cannot resolve the enum, drop the filter entirely + continue; + } + + $out[$targetField] = $enumValue; + continue; + } + + $out[$targetField] = $reqValue; + } + + return $out; + } + + /** + * Resolve an enum constant value from a request value. + * + * Example: + * - enumClass: App\Lib\Enum\StatusEnum::class + * - reqValue: "active" + * Produces: + * - constant("App\\Lib\\Enum\\StatusEnum::Active") => "A" + * + * Returns null if the class/constant is not defined. + * + * @param class-string $enumClass Enum class name (FQCN) + * @param mixed $reqValue Request value (eg "active") + * @return string|null Enum constant value, or null if not resolvable + * @since COmanage Registry v5.3.0 + */ + protected function resolveEnumValue(string $enumClass, mixed $reqValue): ?string + { + if (!is_string($reqValue) || $reqValue === '') { + return null; + } + + if (!class_exists($enumClass)) { + return null; + } + + $constName = $enumClass . '::' . ucfirst($reqValue); + + if (!defined($constName)) { + return null; + } + + $val = constant($constName); + + return is_string($val) ? $val : null; + } +} From a1660c1368fdc26ca8b28152004dd88e3df80cc9 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 7 Jul 2026 07:49:34 +0000 Subject: [PATCH 06/16] Fix index slim response. Fixed Delete response. --- .../src/Controller/PersonProfileApiV2Controller.php | 6 ++++-- .../PersonProfileApiV2/json/person_profile_slim.php | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index 6e57f553b..e25e15b5c 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -456,8 +456,10 @@ public function delete(string $coid, string $identifier): void $entity = $People->findById($personId)->firstOrFail(); $People->deleteOrFail($entity); - $resultCode = 200; - $results = []; + // DELETE succeeded: return 204 No Content (empty body) + $this->response = $this->response->withStatus(204); + $this->disableAutoRender(); + return; } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { $resultCode = 404; $results = ['error' => $e->getMessage()]; diff --git a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php index 15b222c7d..6a52c2da6 100644 --- a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php @@ -2,8 +2,6 @@ declare(strict_types=1); -use Cake\ORM\TableRegistry; - if (!empty($vv_people) && is_iterable($vv_people)) { $page = (int)($vv_page ?? 1); $limit = (int)($vv_limit ?? 100); @@ -14,7 +12,7 @@ if (!empty($person->identifiers)) { foreach ($person->identifiers as $id) { if($vv_person_profile_cfg->identifier_type_id == $id->type_id) { - $items[] = $id->identifier ?? null; + $items[$person->id] = $id->identifier ?? null; } } } @@ -24,8 +22,8 @@ $startIndex = ($total === 0) ? 0 : (($page - 1) * $limit) + 1; $out = []; - foreach (array_values($items) as $i => $item) { - $out[$i] = $item; + foreach (array_values($items) as $personId => $identifier) { + $out[$personId] = $identifier; } $out['currentPage'] = (string)$page; From fe8c2c2b0578d01b3576531686390ce5dbf8d75d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 7 Jul 2026 07:54:29 +0000 Subject: [PATCH 07/16] Fix index slim response. Fixed Delete response. --- .../templates/PersonProfileApiV2/json/person_profile_slim.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php index 6a52c2da6..31332b768 100644 --- a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php @@ -22,8 +22,8 @@ $startIndex = ($total === 0) ? 0 : (($page - 1) * $limit) + 1; $out = []; - foreach (array_values($items) as $personId => $identifier) { - $out[$personId] = $identifier; + foreach (array_values($items) as $id => $identifier) { + $out[$id] = $identifier; } $out['currentPage'] = (string)$page; From bc429ef89c6aad1562d6e3e33ea3894e073845ed Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 7 Jul 2026 14:21:23 +0000 Subject: [PATCH 08/16] Fix full profile to include nested person and external identity roles mveas --- app/plugins/CoreApi/config/message.json | 223 ++++---- .../src/Model/Table/PersonProfilesTable.php | 28 +- .../json/person_profile.php | 531 ++++++++++-------- 3 files changed, 439 insertions(+), 343 deletions(-) diff --git a/app/plugins/CoreApi/config/message.json b/app/plugins/CoreApi/config/message.json index 72ece2b5c..40915a119 100644 --- a/app/plugins/CoreApi/config/message.json +++ b/app/plugins/CoreApi/config/message.json @@ -3,7 +3,6 @@ "$id": "https://example.invalid/comanage/coreapi/v5/message.json", "title": "COmanage Core API Message Format (v5)", "description": "COmanage Core API Message Format (v5) for Person Profiles API v2", - "definitions": { "meta": { "type": "object", @@ -59,7 +58,6 @@ "additionalProperties": false } }, - "type": "object", "properties": { "Person": { @@ -74,7 +72,7 @@ }, "date_of_birth": { "description": "Person date of birth", - "type": "string", + "type": ["string", "null"], "format": "date" }, "status": { @@ -83,13 +81,12 @@ }, "timezone": { "description": "Preferred timezone of this Person, for UI purposes", - "type": "string" + "type": ["string", "null"] } }, "required": ["co_id", "status"], "additionalProperties": false }, - "GroupMember": { "type": "array", "description": "Memberships of the Person in Groups.", @@ -97,30 +94,29 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "co_group_id": { + "group_id": { "description": "Group ID for this membership", "type": "integer", "readOnly": true }, "member": { "description": "If this Person is a member of this group", - "type": "boolean" + "type": ["boolean", "null"] }, "owner": { "description": "If this Person is an owner of this group", - "type": "boolean" + "type": ["boolean", "null"] }, - "co_group_nesting_id": { + "group_nesting_id": { "description": "Group nesting that created this membership, if set", - "type": "integer", + "type": ["integer", "null"], "readOnly": true } }, - "required": ["co_group_id"], + "required": ["group_id"], "additionalProperties": false } }, - "PersonRole": { "type": "array", "description": "Roles for the Person (v5 equivalent of v4 CoPersonRole).", @@ -128,89 +124,85 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "cou_id": { "description": "COU for this Role", "type": "integer" }, - "title": { "description": "Title for this Role", "type": "string" }, - "o": { "description": "Organization for this Role", "type": "string" }, - "ou": { "description": "Department for this Role", "type": "string" }, + "cou_id": { "description": "COU for this Role", "type": ["integer", "null"] }, + "title": { "description": "Title for this Role", "type": ["string", "null"] }, + "o": { "description": "Organization for this Role", "type": ["string", "null"] }, + "ou": { "description": "Department for this Role", "type": ["string", "null"] }, "valid_from": { "description": "Valid from time for this Role", - "type": "string", + "type": ["string", "null"], "format": "date-time" }, "valid_through": { "description": "Valid through time for this Role", - "type": "string", + "type": ["string", "null"], "format": "date-time" }, - "status": { "description": "Person Role status", "type": "string" }, + "status": { "description": "Person Role status", "type": ["string", "null"] }, "sponsor_person_id": { "description": "Sponsor Person ID for this Role", - "type": "integer" + "type": ["integer", "null"] }, - "affiliation": { "description": "Person Role affiliation", "type": "string" }, + "affiliation": { "description": "Person Role affiliation", "type": ["string", "null"] }, "ordr": { "description": "Order of this Role, relative to other roles for this person", - "type": "integer" + "type": ["integer", "null"] }, - "Address": { "type": "array", "items": { "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "country": { "description": "Country for this Address", "type": "string" }, - "description": { "description": "Description of this Address", "type": "string" }, - "language": { "description": "Language encoding of this Address", "type": "string" }, - "locality": { "description": "Locality (eg: city) of this Address", "type": "string" }, - "postal_code": { "description": "Postal code of this Address", "type": "string" }, - "room": { "description": "Room associated with this Address", "type": "string" }, - "state": { "description": "State of this Address", "type": "string" }, - "street": { "description": "Street of this Address", "type": "string" }, - "type": { "description": "Type of this Address", "type": "string" } + "country": { "description": "Country for this Address", "type": ["string", "null"] }, + "description": { "description": "Description of this Address", "type": ["string", "null"] }, + "language": { "description": "Language encoding of this Address", "type": ["string", "null"] }, + "locality": { "description": "Locality (eg: city) of this Address", "type": ["string", "null"] }, + "postal_code": { "description": "Postal code of this Address", "type": ["string", "null"] }, + "room": { "description": "Room associated with this Address", "type": ["string", "null"] }, + "state": { "description": "State of this Address", "type": ["string", "null"] }, + "street": { "description": "Street of this Address", "type": ["string", "null"] }, + "type": { "description": "Type of this Address", "type": ["string", "null"] } }, "required": [], "additionalProperties": false } }, - "AdHocAttribute": { "type": "array", "items": { "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "tag": { "description": "Tag for this Ad Hoc Attribute", "type": "string" }, - "value": { "description": "Value of this Ad Hoc Attribute", "type": "string" } + "tag": { "description": "Tag for this Ad Hoc Attribute", "type": ["string", "null"] }, + "value": { "description": "Value of this Ad Hoc Attribute", "type": ["string", "null"] } }, "required": ["tag"], "additionalProperties": false } }, - "TelephoneNumber": { "type": "array", "items": { "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "country_code": { "description": "Country code for this Telephone Number", "type": "string" }, - "area_code": { "description": "Area code for this Telephone Number", "type": "string" }, - "number": { "description": "Number for this Telephone Number", "type": "string" }, - "extension": { "description": "Extension for this Telephone Number", "type": "string" }, - "description": { "description": "Description of this Telephone Number", "type": "string" }, - "type": { "description": "Type of this Telephone Number", "type": "string" } + "country_code": { "description": "Country code for this Telephone Number", "type": ["string", "null"] }, + "area_code": { "description": "Area code for this Telephone Number", "type": ["string", "null"] }, + "number": { "description": "Number for this Telephone Number", "type": ["string", "null"] }, + "extension": { "description": "Extension for this Telephone Number", "type": ["string", "null"] }, + "description": { "description": "Description of this Telephone Number", "type": ["string", "null"] }, + "type": { "description": "Type of this Telephone Number", "type": ["string", "null"] } }, "required": ["number"], "additionalProperties": false } } }, - "required": ["affiliation", "status"], + "required": [], "additionalProperties": false } }, - "EmailAddress": { "type": "array", "description": "Email addresses for the Person.", @@ -220,20 +212,19 @@ "meta": { "$ref": "#/definitions/meta" }, "mail": { "description": "An email address for this person", - "type": "string", + "type": ["string", "null"], "format": "email" }, - "type": { "description": "The type of Email Address", "type": "string" }, + "type": { "description": "The type of Email Address", "type": ["string", "null"] }, "verified": { "description": "Whether this Email Address has been verified", - "type": "boolean" + "type": ["boolean", "null"] } }, "required": ["mail"], "additionalProperties": false } }, - "Identifier": { "type": "array", "description": "Identifiers for the Person.", @@ -241,19 +232,18 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "identifier": { "description": "An identifier for the person", "type": "string" }, + "identifier": { "description": "An identifier for the person", "type": ["string", "null"] }, "login": { "description": "Whether this Identifier can be used to login to Registry", - "type": "boolean" + "type": ["boolean", "null"] }, - "status": { "description": "Identifier status", "type": "string" }, - "type": { "description": "The type of Identifier", "type": "string" } + "status": { "description": "Identifier status", "type": ["string", "null"] }, + "type": { "description": "The type of Identifier", "type": ["string", "null"] } }, "required": ["identifier"], "additionalProperties": false } }, - "Name": { "type": "array", "description": "Names for the Person.", @@ -261,21 +251,20 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "family": { "description": "The family or surname", "type": "string" }, - "formatted": { "description": "The fully formatted name", "type": "string" }, - "given": { "description": "The given or first name", "type": "string" }, - "language": { "description": "The language encoding for this Name", "type": "string" }, - "middle": { "description": "The middle name", "type": "string" }, - "prefix": { "description": "The honorific or prefix for the Name", "type": "string" }, - "primary_name": { "description": "Whether this is the primary Name", "type": "boolean" }, - "suffix": { "description": "The suffix for this Name", "type": "string" }, - "type": { "description": "The type of Name", "type": "string" } + "family": { "description": "The family or surname", "type": ["string", "null"] }, + "formatted": { "description": "The fully formatted name", "type": ["string", "null"] }, + "given": { "description": "The given or first name", "type": ["string", "null"] }, + "language": { "description": "The language encoding for this Name", "type": ["string", "null"] }, + "middle": { "description": "The middle name", "type": ["string", "null"] }, + "prefix": { "description": "The honorific or prefix for the Name", "type": ["string", "null"] }, + "primary_name": { "description": "Whether this is the primary Name", "type": ["boolean", "null"] }, + "suffix": { "description": "The suffix for this Name", "type": ["string", "null"] }, + "type": { "description": "The type of Name", "type": ["string", "null"] } }, "required": ["given"], "additionalProperties": false } }, - "Url": { "type": "array", "description": "URLs for the Person.", @@ -283,15 +272,14 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "description": { "description": "Description of this URL", "type": "string" }, - "url": { "description": "A URL", "type": "string", "format": "uri" }, - "type": { "description": "The type of URL", "type": "string" } + "description": { "description": "Description of this URL", "type": ["string", "null"] }, + "url": { "description": "A URL", "type": ["string", "null"], "format": "uri" }, + "type": { "description": "The type of URL", "type": ["string", "null"] } }, "required": ["url"], "additionalProperties": false } }, - "SshKey": { "type": "array", "description": "SSH keys for the Person.", @@ -299,20 +287,19 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "comment": { "description": "Comment for this SSH Key", "type": "string" }, - "type": { "description": "SSH Key type", "type": "string" }, - "skey": { "description": "SSH Key", "type": "string" }, + "comment": { "description": "Comment for this SSH Key", "type": ["string", "null"] }, + "type": { "description": "SSH Key type", "type": ["string", "null"] }, + "skey": { "description": "SSH Key", "type": ["string", "null"] }, "ssh_key_authenticator_id": { "description": "SSH Key Authenticator ID associated with this SSH Key", - "type": "integer", + "type": ["integer", "null"], "readOnly": true } }, - "required": ["type", "skey"], + "required": [], "additionalProperties": false } }, - "Certificate": { "type": "array", "description": "Certificates for the Person.", @@ -320,22 +307,21 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "description": { "description": "Description of this Certificate", "type": "string" }, - "subject_dn": { "description": "Subject DN of this Certificate", "type": "string" }, - "issuer_in": { "description": "Issuer DN of this Certificate", "type": "string" }, - "valid_from": { "description": "Valid from time for this Certificate", "type": "string", "format": "date-time" }, - "valid_through": { "description": "Valid through time for this Certificate", "type": "string", "format": "date-time" }, + "description": { "description": "Description of this Certificate", "type": ["string", "null"] }, + "subject_dn": { "description": "Subject DN of this Certificate", "type": ["string", "null"] }, + "issuer_in": { "description": "Issuer DN of this Certificate", "type": ["string", "null"] }, + "valid_from": { "description": "Valid from time for this Certificate", "type": ["string", "null"], "format": "date-time" }, + "valid_through": { "description": "Valid through time for this Certificate", "type": ["string", "null"], "format": "date-time" }, "certificate_authenticator_id": { "description": "Certificate Authenticator ID associated with this Certificate", - "type": "integer", + "type": ["integer", "null"], "readOnly": true } }, - "required": ["subject_dn"], + "required": [], "additionalProperties": false } }, - "Password": { "type": "array", "description": "Passwords for the Person.", @@ -343,19 +329,18 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "password": { "description": "Password", "type": "string" }, - "password_type": { "description": "Password (hash) type", "type": "string" }, + "password": { "description": "Password", "type": ["string", "null"] }, + "password_type": { "description": "Password (hash) type", "type": ["string", "null"] }, "password_authenticator_id": { "description": "Password Authenticator ID associated with this Password", - "type": "integer", + "type": ["integer", "null"], "readOnly": true } }, - "required": ["password", "password_type"], + "required": [], "additionalProperties": false } }, - "UnixClusterAccount": { "type": "array", "description": "Unix cluster accounts for the Person.", @@ -363,19 +348,19 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "sync_mode": { "description": "Sync Mode for this Unix Cluster Account", "type": "string" }, - "status": { "description": "Status for this Unix Cluster Account", "type": "string" }, - "username": { "description": "Username for this Unix Cluster Account", "type": "string" }, - "uid": { "description": "UID for this Unix Cluster Account", "type": "string" }, - "gecos": { "description": "GECOS for this Unix Cluster Account", "type": "string" }, - "login_shell": { "description": "Login shell for this Unix Cluster Account", "type": "string" }, - "home_directory": { "description": "Home directory for this Unix Cluster Account", "type": "string" }, - "primary_co_group_id": { "description": "Primary group for this Unix Cluster Account", "type": "string" }, - "valid_from": { "description": "Valid from time for this Unix Cluster Account", "type": "string", "format": "date-time" }, - "valid_through": { "description": "Valid through time for this Unix Cluster Account", "type": "string", "format": "date-time" }, + "sync_mode": { "description": "Sync Mode for this Unix Cluster Account", "type": ["string", "null"] }, + "status": { "description": "Status for this Unix Cluster Account", "type": ["string", "null"] }, + "username": { "description": "Username for this Unix Cluster Account", "type": ["string", "null"] }, + "uid": { "description": "UID for this Unix Cluster Account", "type": ["string", "null"] }, + "gecos": { "description": "GECOS for this Unix Cluster Account", "type": ["string", "null"] }, + "login_shell": { "description": "Login shell for this Unix Cluster Account", "type": ["string", "null"] }, + "home_directory": { "description": "Home directory for this Unix Cluster Account", "type": ["string", "null"] }, + "primary_group_id": { "description": "Primary group for this Unix Cluster Account", "type": ["string", "null"] }, + "valid_from": { "description": "Valid from time for this Unix Cluster Account", "type": ["string", "null"], "format": "date-time" }, + "valid_through": { "description": "Valid through time for this Unix Cluster Account", "type": ["string", "null"], "format": "date-time" }, "unix_cluster_id": { "description": "Unix Cluster ID associated with this Unix Cluster Account", - "type": "integer", + "type": ["integer", "null"], "readOnly": true } }, @@ -383,7 +368,6 @@ "additionalProperties": false } }, - "ExternalIdentity": { "type": "array", "description": "External identities linked to the Person (read-only in this API surface).", @@ -392,16 +376,38 @@ "type": "object", "properties": { "meta": { "$ref": "#/definitions/meta" }, - "co_id": { "description": "CO for this External Identity", "type": "integer" }, - "title": { "description": "Title for this External Identity", "type": "string" }, - "o": { "description": "Organization for this External Identity", "type": "string" }, - "ou": { "description": "Department for this External Identity", "type": "string" }, - "valid_from": { "description": "Valid from time for this External Identity", "type": "string", "format": "date-time" }, - "valid_through": { "description": "Valid through time for this External Identity", "type": "string", "format": "date-time" }, - "status": { "description": "External Identity status", "type": "string" }, - "affiliation": { "description": "External Identity affiliation", "type": "string" }, - "date_of_birth": { "description": "External Identity date of birth", "type": "string", "format": "date" }, - + "co_id": { "description": "CO for this External Identity", "type": ["integer", "null"] }, + "title": { "description": "Title for this External Identity", "type": ["string", "null"] }, + "o": { "description": "Organization for this External Identity", "type": ["string", "null"] }, + "ou": { "description": "Department for this External Identity", "type": ["string", "null"] }, + "valid_from": { "description": "Valid from time for this External Identity", "type": ["string", "null"], "format": "date-time" }, + "valid_through": { "description": "Valid through time for this External Identity", "type": ["string", "null"], "format": "date-time" }, + "status": { "description": "External Identity status", "type": ["string", "null"] }, + "affiliation": { "description": "External Identity affiliation", "type": ["string", "null"] }, + "date_of_birth": { "description": "External Identity date of birth", "type": ["string", "null"], "format": "date" }, + "ExternalIdentityRole": { + "type": "array", + "description": "Roles associated with this External Identity.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "title": { "description": "Title for this External Identity Role", "type": ["string", "null"] }, + "o": { "description": "Organization for this External Identity Role", "type": ["string", "null"] }, + "ou": { "description": "Department for this External Identity Role", "type": ["string", "null"] }, + "valid_from": { "description": "Valid from time for this External Identity Role", "type": ["string", "null"], "format": "date-time" }, + "valid_through": { "description": "Valid through time for this External Identity Role", "type": ["string", "null"], "format": "date-time" }, + "status": { "description": "External Identity Role status", "type": ["string", "null"] }, + "affiliation": { "description": "External Identity Role affiliation", "type": ["string", "null"] }, + "ordr": { "description": "Order of this Role, relative to other roles for this external identity", "type": ["integer", "null"] }, + "Address": { "$ref": "#/properties/PersonRole/items/properties/Address" }, + "AdHocAttribute": { "$ref": "#/properties/PersonRole/items/properties/AdHocAttribute" }, + "TelephoneNumber": { "$ref": "#/properties/PersonRole/items/properties/TelephoneNumber" } + }, + "required": [], + "additionalProperties": false + } + }, "Address": { "$ref": "#/properties/PersonRole/items/properties/Address" }, "AdHocAttribute": { "$ref": "#/properties/PersonRole/items/properties/AdHocAttribute" }, "EmailAddress": { "$ref": "#/properties/EmailAddress" }, @@ -415,7 +421,6 @@ } } }, - "required": ["Person"], "additionalProperties": false } diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php index 5e8a8c364..5733ff064 100644 --- a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -171,20 +171,44 @@ public function getPersonProfileContain(): array if ($People->associations()->has('PersonRoles')) { $personRolesTarget = $People->associations()->get('PersonRoles')->getTarget(); + $personRolesContain = $this->getOwnedAssociationNames($personRolesTarget); + + // Also include the affiliation type lookup (belongsTo Types as affiliation_type) + if ($personRolesTarget->associations()->has('Types')) { + $personRolesContain[] = 'Types'; + } + $contain = $this->replaceContainEntry( $contain, 'PersonRoles', - $this->getOwnedAssociationNames($personRolesTarget) + $personRolesContain ); } if ($People->associations()->has('ExternalIdentities')) { $externalIdentitiesTarget = $People->associations()->get('ExternalIdentities')->getTarget(); + $externalIdentitiesContain = $this->getOwnedAssociationNames($externalIdentitiesTarget); + + // ExternalIdentity affiliation/title/etc comes from ExternalIdentityRoles + if ($externalIdentitiesTarget->associations()->has('ExternalIdentityRoles')) { + $externalIdentityRolesTarget = $externalIdentitiesTarget->associations()->get('ExternalIdentityRoles')->getTarget(); + + // Pull all owned children (MVEAs) for ExternalIdentityRoles (Addresses, AdHocAttributes, TelephoneNumbers, etc) + $externalIdentityRolesContain = $this->getOwnedAssociationNames($externalIdentityRolesTarget); + + // Also include the affiliation type lookup (belongsTo Types as affiliation_type) + if ($externalIdentityRolesTarget->associations()->has('Types')) { + $externalIdentityRolesContain[] = 'Types'; + } + + $externalIdentitiesContain['ExternalIdentityRoles'] = $externalIdentityRolesContain; + } + $contain = $this->replaceContainEntry( $contain, 'ExternalIdentities', - $this->getOwnedAssociationNames($externalIdentitiesTarget) + $externalIdentitiesContain ); } diff --git a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php index bae7cb650..36bdf94b2 100644 --- a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php @@ -66,7 +66,298 @@ return $meta; }; -$formatPersonProfile = function (object $person) use ($formatMeta, $typeLabel): array { +$formatAddressList = function (iterable $addresses) use ($formatMeta, $typeLabel): array { + $out = []; + + foreach ($addresses as $a) { + $out[] = [ + 'meta' => $formatMeta($a), + 'country' => $a->country ?? null, + 'description' => $a->description ?? null, + 'language' => $a->language ?? null, + 'locality' => $a->locality ?? null, + 'postal_code' => $a->postal_code ?? null, + 'room' => $a->room ?? null, + 'state' => $a->state ?? null, + 'street' => $a->street ?? null, + 'type' => $typeLabel($a->type_id ?? null), + ]; + } + + return $out; +}; + +$formatAdHocAttributeList = function (iterable $attrs) use ($formatMeta): array { + $out = []; + + foreach ($attrs as $aha) { + $out[] = [ + 'meta' => $formatMeta($aha), + 'tag' => $aha->tag ?? null, + 'value' => $aha->value ?? null, + ]; + } + + return $out; +}; + +$formatTelephoneNumberList = function (iterable $tels) use ($formatMeta, $typeLabel): array { + $out = []; + + foreach ($tels as $tn) { + $out[] = [ + 'meta' => $formatMeta($tn), + 'country_code' => $tn->country_code ?? null, + 'area_code' => $tn->area_code ?? null, + 'number' => $tn->number ?? null, + 'extension' => $tn->extension ?? null, + 'description' => $tn->description ?? null, + 'type' => $typeLabel($tn->type_id ?? null), + ]; + } + + return $out; +}; + +$formatEmailAddressList = function (iterable $emails) use ($formatMeta, $typeLabel): array { + $out = []; + + foreach ($emails as $ea) { + $out[] = [ + 'meta' => $formatMeta($ea), + 'mail' => $ea->mail ?? null, + 'type' => $typeLabel($ea->type_id ?? null), + 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, + ]; + } + + return $out; +}; + +$formatIdentifierList = function (iterable $identifiers) use ($formatMeta, $typeLabel): array { + $out = []; + + foreach ($identifiers as $id) { + $out[] = [ + 'meta' => $formatMeta($id), + 'identifier' => $id->identifier ?? null, + 'login' => isset($id->login) ? (bool)$id->login : null, + 'status' => $id->status ?? null, + 'type' => $typeLabel($id->type_id ?? null), + ]; + } + + return $out; +}; + +$formatNameList = function (iterable $names) use ($formatMeta, $typeLabel): array { + $out = []; + + foreach ($names as $n) { + $out[] = [ + 'meta' => $formatMeta($n), + 'family' => $n->family ?? null, + 'formatted' => $n->display_name ?? null, + 'given' => $n->given ?? null, + 'language' => $n->language ?? null, + 'middle' => $n->middle ?? null, + 'prefix' => $n->honorific ?? null, + 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, + 'suffix' => $n->suffix ?? null, + 'type' => $typeLabel($n->type_id ?? null), + ]; + } + + return $out; +}; + +$formatUrlList = function (iterable $urls) use ($formatMeta, $typeLabel): array { + $out = []; + + foreach ($urls as $u) { + $out[] = [ + 'meta' => $formatMeta($u), + 'description' => $u->description ?? null, + 'url' => $u->url ?? null, + 'type' => $typeLabel($u->type_id ?? null), + ]; + } + + return $out; +}; + +$formatPersonRoleList = function (iterable $personRoles) use ( + $formatMeta, + $formatAddressList, + $formatAdHocAttributeList, + $formatTelephoneNumberList +): array { + $out = []; + + foreach ($personRoles as $pr) { + $role = [ + 'meta' => $formatMeta($pr), + 'cou_id' => $pr->cou_id ?? null, + 'title' => $pr->title ?? null, + 'o' => $pr->organization ?? null, + 'ou' => $pr->department ?? null, + 'valid_from' => $pr->valid_from ?? null, + 'valid_through' => $pr->valid_through ?? null, + 'status' => $pr->status ?? null, + 'sponsor_person_id' => $pr->sponsor_person_id ?? null, + 'affiliation' => $pr->affiliation_type->value ?? null, + 'ordr' => $pr->ordr ?? null, + ]; + + // Include role-level MVEAs whenever the association is present on the entity + if (isset($pr->addresses)) { + $role['Address'] = !empty($pr->addresses) ? $formatAddressList($pr->addresses) : []; + } + if (isset($pr->ad_hoc_attributes)) { + $role['AdHocAttribute'] = !empty($pr->ad_hoc_attributes) ? $formatAdHocAttributeList($pr->ad_hoc_attributes) : []; + } + if (isset($pr->telephone_numbers)) { + $role['TelephoneNumber'] = !empty($pr->telephone_numbers) ? $formatTelephoneNumberList($pr->telephone_numbers) : []; + } + + $out[] = $role; + } + + return $out; +}; + +$formatExternalIdentityRoleList = function (iterable $externalIdentityRoles) use ( + $formatMeta, + $formatAddressList, + $formatAdHocAttributeList, + $formatTelephoneNumberList +): array { + $out = []; + + foreach ($externalIdentityRoles as $eir) { + $role = [ + 'meta' => $formatMeta($eir), + 'title' => $eir->title ?? null, + 'o' => $eir->organization ?? null, + 'ou' => $eir->department ?? null, + 'valid_from' => $eir->valid_from ?? null, + 'valid_through' => $eir->valid_through ?? null, + 'status' => $eir->status ?? null, + 'affiliation' => $eir->affiliation_type->value ?? null, + 'ordr' => $eir->ordr ?? null, + ]; + + // Include EIR-level MVEAs whenever the association is present on the entity + if (isset($eir->addresses)) { + $role['Address'] = !empty($eir->addresses) ? $formatAddressList($eir->addresses) : []; + } + if (isset($eir->ad_hoc_attributes)) { + $role['AdHocAttribute'] = !empty($eir->ad_hoc_attributes) ? $formatAdHocAttributeList($eir->ad_hoc_attributes) : []; + } + if (isset($eir->telephone_numbers)) { + $role['TelephoneNumber'] = !empty($eir->telephone_numbers) ? $formatTelephoneNumberList($eir->telephone_numbers) : []; + } + + $out[] = $role; + } + + return $out; +}; + +$formatExternalIdentityList = function (iterable $externalIdentities) use ( + $formatMeta, + $formatAddressList, + $formatAdHocAttributeList, + $formatTelephoneNumberList, + $formatEmailAddressList, + $formatIdentifierList, + $formatNameList, + $formatUrlList, + $formatExternalIdentityRoleList +): array { + $out = []; + + foreach ($externalIdentities as $ei) { + $ext = [ + 'meta' => $formatMeta($ei), + 'co_id' => $ei->co_id ?? null, + 'title' => $ei->title ?? null, + 'o' => $ei->organization ?? null, + 'ou' => $ei->department ?? null, + 'valid_from' => $ei->valid_from ?? null, + 'valid_through' => $ei->valid_through ?? null, + 'status' => $ei->status ?? null, + 'affiliation' => null, + 'date_of_birth' => $ei->date_of_birth ?? null, + ]; + + // Cake hasMany ExternalIdentityRoles => entity field external_identity_roles + if (isset($ei->external_identity_roles)) { + $ext['ExternalIdentityRole'] = !empty($ei->external_identity_roles) + ? $formatExternalIdentityRoleList($ei->external_identity_roles) + : []; + } + + if (!empty($ei->addresses)) { + $ext['Address'] = $formatAddressList($ei->addresses); + } + + if (!empty($ei->ad_hoc_attributes)) { + $ext['AdHocAttribute'] = $formatAdHocAttributeList($ei->ad_hoc_attributes); + } + + if (!empty($ei->email_addresses)) { + $ext['EmailAddress'] = $formatEmailAddressList($ei->email_addresses); + } + + if (!empty($ei->identifiers)) { + $ext['Identifier'] = $formatIdentifierList($ei->identifiers); + } + + if (!empty($ei->names)) { + $ext['Name'] = $formatNameList($ei->names); + } + + if (!empty($ei->telephone_numbers)) { + $ext['TelephoneNumber'] = $formatTelephoneNumberList($ei->telephone_numbers); + } + + if (!empty($ei->urls)) { + $ext['Url'] = $formatUrlList($ei->urls); + } + + $out[] = $ext; + } + + return $out; +}; + +$formatGroupMemberList = function (iterable $groupMembers) use ($formatMeta): array { + $out = []; + + foreach ($groupMembers as $gm) { + $out[] = [ + 'meta' => $formatMeta($gm), + 'group_id' => (int)($gm->group_id ?? 0), + 'member' => isset($gm->member) ? (bool)$gm->member : null, + 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, + 'group_nesting_id' => $gm->group_nesting_id ?? null, + ]; + } + + return $out; +}; + +$formatPersonProfile = function (object $person) use ( + $formatMeta, + $formatGroupMemberList, + $formatEmailAddressList, + $formatIdentifierList, + $formatNameList, + $formatUrlList, + $formatPersonRoleList, + $formatExternalIdentityList +): array { $out = [ 'Person' => [ 'meta' => $formatMeta($person), @@ -78,255 +369,31 @@ ]; if (!empty($person->group_members)) { - $out['GroupMember'] = []; - foreach ($person->group_members as $gm) { - $out['GroupMember'][] = [ - 'meta' => $formatMeta($gm), - 'group_id' => (int)($gm->group_id ?? 0), - 'member' => isset($gm->member) ? (bool)$gm->member : null, - 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, - 'group_nesting_id' => $gm->group_nesting_id ?? null, - ]; - } + $out['GroupMember'] = $formatGroupMemberList($person->group_members); } if (!empty($person->email_addresses)) { - $out['EmailAddress'] = []; - foreach ($person->email_addresses as $ea) { - $out['EmailAddress'][] = [ - 'meta' => $formatMeta($ea), - 'mail' => $ea->mail ?? null, - 'type' => $typeLabel($ea->type_id ?? null), - 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, - ]; - } + $out['EmailAddress'] = $formatEmailAddressList($person->email_addresses); } if (!empty($person->identifiers)) { - $out['Identifier'] = []; - foreach ($person->identifiers as $id) { - $out['Identifier'][] = [ - 'meta' => $formatMeta($id), - 'identifier' => $id->identifier ?? null, - 'login' => isset($id->login) ? (bool)$id->login : null, - 'status' => $id->status ?? null, - 'type' => $typeLabel($id->type_id ?? null), - ]; - } + $out['Identifier'] = $formatIdentifierList($person->identifiers); } if (!empty($person->names)) { - $out['Name'] = []; - foreach ($person->names as $n) { - $out['Name'][] = [ - 'meta' => $formatMeta($n), - 'family' => $n->family ?? null, - 'formatted' => $n->display_name ?? null, - 'given' => $n->given ?? null, - 'language' => $n->language ?? null, - 'middle' => $n->middle ?? null, - 'prefix' => $n->honorific ?? null, - 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, - 'suffix' => $n->suffix ?? null, - 'type' => $typeLabel($n->type_id ?? null), - ]; - } + $out['Name'] = $formatNameList($person->names); } if (!empty($person->urls)) { - $out['Url'] = []; - foreach ($person->urls as $u) { - $out['Url'][] = [ - 'meta' => $formatMeta($u), - 'description' => $u->description ?? null, - 'url' => $u->url ?? null, - 'type' => $typeLabel($u->type_id ?? null), - ]; - } + $out['Url'] = $formatUrlList($person->urls); } if (!empty($person->person_roles)) { - $out['PersonRole'] = []; - foreach ($person->person_roles as $pr) { - $role = [ - 'meta' => $formatMeta($pr), - 'cou_id' => $pr->cou_id ?? null, - 'title' => $pr->title ?? null, - 'o' => $pr->organization ?? null, - 'ou' => $pr->department ?? null, - 'valid_from' => $pr->valid_from ?? null, - 'valid_through' => $pr->valid_through ?? null, - 'status' => $pr->status ?? null, - 'sponsor_person_id' => $pr->sponsor_person_id ?? null, - 'affiliation' => $pr->affiliation ?? null, - 'ordr' => $pr->ordr ?? null, - ]; - - if (!empty($pr->addresses)) { - $role['Address'] = []; - foreach ($pr->addresses as $a) { - $role['Address'][] = [ - 'meta' => $formatMeta($a), - 'country' => $a->country ?? null, - 'description' => $a->description ?? null, - 'language' => $a->language ?? null, - 'locality' => $a->locality ?? null, - 'postal_code' => $a->postal_code ?? null, - 'room' => $a->room ?? null, - 'state' => $a->state ?? null, - 'street' => $a->street ?? null, - 'type' => $typeLabel($a->type_id ?? null), - ]; - } - } - - if (!empty($pr->ad_hoc_attributes)) { - $role['AdHocAttribute'] = []; - foreach ($pr->ad_hoc_attributes as $aha) { - $role['AdHocAttribute'][] = [ - 'meta' => $formatMeta($aha), - 'tag' => $aha->tag ?? null, - 'value' => $aha->value ?? null, - ]; - } - } - - if (!empty($pr->telephone_numbers)) { - $role['TelephoneNumber'] = []; - foreach ($pr->telephone_numbers as $tn) { - $role['TelephoneNumber'][] = [ - 'meta' => $formatMeta($tn), - 'country_code' => $tn->country_code ?? null, - 'area_code' => $tn->area_code ?? null, - 'number' => $tn->number ?? null, - 'extension' => $tn->extension ?? null, - 'description' => $tn->description ?? null, - 'type' => $typeLabel($tn->type_id ?? null), - ]; - } - } - - $out['PersonRole'][] = $role; - } + $out['PersonRole'] = $formatPersonRoleList($person->person_roles); } if (!empty($person->external_identities)) { - $out['ExternalIdentity'] = []; - foreach ($person->external_identities as $ei) { - $ext = [ - 'meta' => $formatMeta($ei), - 'co_id' => $ei->co_id ?? null, - 'title' => $ei->title ?? null, - 'o' => $ei->organization ?? null, - 'ou' => $ei->department ?? null, - 'valid_from' => $ei->valid_from ?? null, - 'valid_through' => $ei->valid_through ?? null, - 'status' => $ei->status ?? null, - 'affiliation' => $ei->affiliation ?? null, - 'date_of_birth' => $ei->date_of_birth ?? null, - ]; - - if (!empty($ei->addresses)) { - $ext['Address'] = []; - foreach ($ei->addresses as $a) { - $ext['Address'][] = [ - 'meta' => $formatMeta($a), - 'country' => $a->country ?? null, - 'description' => $a->description ?? null, - 'language' => $a->language ?? null, - 'locality' => $a->locality ?? null, - 'postal_code' => $a->postal_code ?? null, - 'room' => $a->room ?? null, - 'state' => $a->state ?? null, - 'street' => $a->street ?? null, - 'type' => $typeLabel($a->type_id ?? null), - ]; - } - } - - if (!empty($ei->ad_hoc_attributes)) { - $ext['AdHocAttribute'] = []; - foreach ($ei->ad_hoc_attributes as $aha) { - $ext['AdHocAttribute'][] = [ - 'meta' => $formatMeta($aha), - 'tag' => $aha->tag ?? null, - 'value' => $aha->value ?? null, - ]; - } - } - - if (!empty($ei->email_addresses)) { - $ext['EmailAddress'] = []; - foreach ($ei->email_addresses as $ea) { - $ext['EmailAddress'][] = [ - 'meta' => $formatMeta($ea), - 'mail' => $ea->mail ?? null, - 'type' => $typeLabel($ea->type_id ?? null), - 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, - ]; - } - } - - if (!empty($ei->identifiers)) { - $ext['Identifier'] = []; - foreach ($ei->identifiers as $id) { - $ext['Identifier'][] = [ - 'meta' => $formatMeta($id), - 'identifier' => $id->identifier ?? null, - 'login' => isset($id->login) ? (bool)$id->login : null, - 'status' => $id->status ?? null, - 'type' => $typeLabel($id->type_id ?? null), - ]; - } - } - - if (!empty($ei->names)) { - $ext['Name'] = []; - foreach ($ei->names as $n) { - $ext['Name'][] = [ - 'meta' => $formatMeta($n), - 'family' => $n->family ?? null, - 'formatted' => $n->display_name ?? null, - 'given' => $n->given ?? null, - 'language' => $n->language ?? null, - 'middle' => $n->middle ?? null, - 'prefix' => $n->honorific ?? null, - 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, - 'suffix' => $n->suffix ?? null, - 'type' => $typeLabel($n->type_id ?? null), - ]; - } - } - - if (!empty($ei->telephone_numbers)) { - $ext['TelephoneNumber'] = []; - foreach ($ei->telephone_numbers as $tn) { - $ext['TelephoneNumber'][] = [ - 'meta' => $formatMeta($tn), - 'country_code' => $tn->country_code ?? null, - 'area_code' => $tn->area_code ?? null, - 'number' => $tn->number ?? null, - 'extension' => $tn->extension ?? null, - 'description' => $tn->description ?? null, - 'type' => $typeLabel($tn->type_id ?? null), - ]; - } - } - - if (!empty($ei->urls)) { - $ext['Url'] = []; - foreach ($ei->urls as $u) { - $ext['Url'][] = [ - 'meta' => $formatMeta($u), - 'description' => $u->description ?? null, - 'url' => $u->url ?? null, - 'type' => $typeLabel($u->type_id ?? null), - ]; - } - } - - $out['ExternalIdentity'][] = $ext; - } + $out['ExternalIdentity'] = $formatExternalIdentityList($person->external_identities); } return $out; From ef408e9387fdd953e0132f8bbf4245619c6863a8 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 7 Jul 2026 14:35:20 +0000 Subject: [PATCH 09/16] Create a ViewHelper for the person profile template --- .../PersonProfileApiV2Controller.php | 2 +- .../src/View/Helper/PersonProfileHelper.php | 524 ++++++++++++++++++ .../json/person_profile.php | 416 +------------- 3 files changed, 553 insertions(+), 389 deletions(-) create mode 100644 app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index e25e15b5c..a35dc799b 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -115,7 +115,6 @@ public function beforeRender(EventInterface $event): void $action = (string)$this->request->getParam('action'); $cfg = $this->person_profile_cfg; - // If the action is 'index' and the response type is 'Full', use the 'person_profile' template. if ($action === 'index' && $cfg->index_response_type == ResponseTypesEnum::Full) { $this->viewBuilder()->setOption('serialize', null) @@ -133,6 +132,7 @@ public function beforeRender(EventInterface $event): void $this->viewBuilder()->setOption('serialize', 'vv_results'); } + $this->viewBuilder()->addHelpers(['CoreApi.PersonProfile']); parent::beforeRender($event); } diff --git a/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php b/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php new file mode 100644 index 000000000..6df4309cb --- /dev/null +++ b/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php @@ -0,0 +1,524 @@ + + * + * @since COmanage v5.3.0 + */ + protected array $typeLabelCache = []; + + /** + * Resolve a type_id to a human label using the Types table. + * + * @param int|null $typeId + * @return string|null + * + * @since COmanage v5.3.0 + */ + public function typeLabel(?int $typeId): ?string + { + if (empty($typeId)) { + return null; + } + + if (isset($this->typeLabelCache[$typeId])) { + return $this->typeLabelCache[$typeId]; + } + + $Types = TableRegistry::getTableLocator()->get('Types'); + + try { + $label = (string)$Types->getTypeLabel($typeId); + } catch (\Exception $e) { + $label = null; + } + + if ($label !== null) { + $this->typeLabelCache[$typeId] = $label; + } + + return $label; + } + + /** + * Format standard COmanage entity metadata. + * + * @param object $entity + * @return array + * + * @since COmanage v5.3.0 + */ + public function formatMeta(object $entity): array + { + $meta = []; + + if (isset($entity->id)) { + $meta['id'] = (int)$entity->id; + } + if (isset($entity->created)) { + $meta['created'] = $entity->created; + } + if (isset($entity->modified)) { + $meta['modified'] = $entity->modified; + } + if (isset($entity->deleted)) { + $meta['deleted'] = (bool)$entity->deleted; + } + if (isset($entity->revision)) { + $meta['revision'] = (int)$entity->revision; + } + if (isset($entity->actor_identifier)) { + $meta['actor_identifier'] = (string)$entity->actor_identifier; + } + + return $meta; + } + + /** + * Format an Address list. + * + * @param iterable $addresses + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatAddressList(iterable $addresses): array + { + $out = []; + + foreach ($addresses as $a) { + $out[] = [ + 'meta' => $this->formatMeta($a), + 'country' => $a->country ?? null, + 'description' => $a->description ?? null, + 'language' => $a->language ?? null, + 'locality' => $a->locality ?? null, + 'postal_code' => $a->postal_code ?? null, + 'room' => $a->room ?? null, + 'state' => $a->state ?? null, + 'street' => $a->street ?? null, + 'type' => $this->typeLabel($a->type_id ?? null), + ]; + } + + return $out; + } + + /** + * Format an AdHocAttribute list. + * + * @param iterable $attrs + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatAdHocAttributeList(iterable $attrs): array + { + $out = []; + + foreach ($attrs as $aha) { + $out[] = [ + 'meta' => $this->formatMeta($aha), + 'tag' => $aha->tag ?? null, + 'value' => $aha->value ?? null, + ]; + } + + return $out; + } + + /** + * Format a TelephoneNumber list. + * + * @param iterable $tels + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatTelephoneNumberList(iterable $tels): array + { + $out = []; + + foreach ($tels as $tn) { + $out[] = [ + 'meta' => $this->formatMeta($tn), + 'country_code' => $tn->country_code ?? null, + 'area_code' => $tn->area_code ?? null, + 'number' => $tn->number ?? null, + 'extension' => $tn->extension ?? null, + 'description' => $tn->description ?? null, + 'type' => $this->typeLabel($tn->type_id ?? null), + ]; + } + + return $out; + } + + /** + * Format an EmailAddress list. + * + * @param iterable $emails + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatEmailAddressList(iterable $emails): array + { + $out = []; + + foreach ($emails as $ea) { + $out[] = [ + 'meta' => $this->formatMeta($ea), + 'mail' => $ea->mail ?? null, + 'type' => $this->typeLabel($ea->type_id ?? null), + 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, + ]; + } + + return $out; + } + + /** + * Format an Identifier list. + * + * @param iterable $identifiers + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatIdentifierList(iterable $identifiers): array + { + $out = []; + + foreach ($identifiers as $id) { + $out[] = [ + 'meta' => $this->formatMeta($id), + 'identifier' => $id->identifier ?? null, + 'login' => isset($id->login) ? (bool)$id->login : null, + 'status' => $id->status ?? null, + 'type' => $this->typeLabel($id->type_id ?? null), + ]; + } + + return $out; + } + + /** + * Format a Name list. + * + * @param iterable $names + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatNameList(iterable $names): array + { + $out = []; + + foreach ($names as $n) { + $out[] = [ + 'meta' => $this->formatMeta($n), + 'family' => $n->family ?? null, + 'formatted' => $n->display_name ?? null, + 'given' => $n->given ?? null, + 'language' => $n->language ?? null, + 'middle' => $n->middle ?? null, + 'prefix' => $n->honorific ?? null, + 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, + 'suffix' => $n->suffix ?? null, + 'type' => $this->typeLabel($n->type_id ?? null), + ]; + } + + return $out; + } + + /** + * Format a Url list. + * + * @param iterable $urls + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatUrlList(iterable $urls): array + { + $out = []; + + foreach ($urls as $u) { + $out[] = [ + 'meta' => $this->formatMeta($u), + 'description' => $u->description ?? null, + 'url' => $u->url ?? null, + 'type' => $this->typeLabel($u->type_id ?? null), + ]; + } + + return $out; + } + + /** + * Format a PersonRole list, including role-level MVEAs if present. + * + * @param iterable $personRoles + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatPersonRoleList(iterable $personRoles): array + { + $out = []; + + foreach ($personRoles as $pr) { + $role = [ + 'meta' => $this->formatMeta($pr), + 'cou_id' => $pr->cou_id ?? null, + 'title' => $pr->title ?? null, + 'o' => $pr->organization ?? null, + 'ou' => $pr->department ?? null, + 'valid_from' => $pr->valid_from ?? null, + 'valid_through' => $pr->valid_through ?? null, + 'status' => $pr->status ?? null, + 'sponsor_person_id' => $pr->sponsor_person_id ?? null, + 'affiliation' => $pr->affiliation_type->value ?? null, + 'ordr' => $pr->ordr ?? null, + ]; + + // Include role-level MVEAs whenever the association is present on the entity + if (isset($pr->addresses)) { + $role['Address'] = !empty($pr->addresses) ? $this->formatAddressList($pr->addresses) : []; + } + if (isset($pr->ad_hoc_attributes)) { + $role['AdHocAttribute'] = !empty($pr->ad_hoc_attributes) ? $this->formatAdHocAttributeList($pr->ad_hoc_attributes) : []; + } + if (isset($pr->telephone_numbers)) { + $role['TelephoneNumber'] = !empty($pr->telephone_numbers) ? $this->formatTelephoneNumberList($pr->telephone_numbers) : []; + } + + $out[] = $role; + } + + return $out; + } + + /** + * Format an ExternalIdentityRole list, including role-level MVEAs if present. + * + * @param iterable $externalIdentityRoles + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatExternalIdentityRoleList(iterable $externalIdentityRoles): array + { + $out = []; + + foreach ($externalIdentityRoles as $eir) { + $role = [ + 'meta' => $this->formatMeta($eir), + 'title' => $eir->title ?? null, + 'o' => $eir->organization ?? null, + 'ou' => $eir->department ?? null, + 'valid_from' => $eir->valid_from ?? null, + 'valid_through' => $eir->valid_through ?? null, + 'status' => $eir->status ?? null, + 'affiliation' => $eir->affiliation_type->value ?? null, + 'ordr' => $eir->ordr ?? null, + ]; + + // Include EIR-level MVEAs whenever the association is present on the entity + if (isset($eir->addresses)) { + $role['Address'] = !empty($eir->addresses) ? $this->formatAddressList($eir->addresses) : []; + } + if (isset($eir->ad_hoc_attributes)) { + $role['AdHocAttribute'] = !empty($eir->ad_hoc_attributes) ? $this->formatAdHocAttributeList($eir->ad_hoc_attributes) : []; + } + if (isset($eir->telephone_numbers)) { + $role['TelephoneNumber'] = !empty($eir->telephone_numbers) ? $this->formatTelephoneNumberList($eir->telephone_numbers) : []; + } + + $out[] = $role; + } + + return $out; + } + + /** + * Format an ExternalIdentity list, including nested associations when present. + * + * @param iterable $externalIdentities + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatExternalIdentityList(iterable $externalIdentities): array + { + $out = []; + + foreach ($externalIdentities as $ei) { + $ext = [ + 'meta' => $this->formatMeta($ei), + 'co_id' => $ei->co_id ?? null, + 'title' => $ei->title ?? null, + 'o' => $ei->organization ?? null, + 'ou' => $ei->department ?? null, + 'valid_from' => $ei->valid_from ?? null, + 'valid_through' => $ei->valid_through ?? null, + 'status' => $ei->status ?? null, + 'affiliation' => null, + 'date_of_birth' => $ei->date_of_birth ?? null, + ]; + + // Cake hasMany ExternalIdentityRoles => entity field external_identity_roles + if (isset($ei->external_identity_roles)) { + $ext['ExternalIdentityRole'] = !empty($ei->external_identity_roles) + ? $this->formatExternalIdentityRoleList($ei->external_identity_roles) + : []; + } + + if (!empty($ei->addresses)) { + $ext['Address'] = $this->formatAddressList($ei->addresses); + } + + if (!empty($ei->ad_hoc_attributes)) { + $ext['AdHocAttribute'] = $this->formatAdHocAttributeList($ei->ad_hoc_attributes); + } + + if (!empty($ei->email_addresses)) { + $ext['EmailAddress'] = $this->formatEmailAddressList($ei->email_addresses); + } + + if (!empty($ei->identifiers)) { + $ext['Identifier'] = $this->formatIdentifierList($ei->identifiers); + } + + if (!empty($ei->names)) { + $ext['Name'] = $this->formatNameList($ei->names); + } + + if (!empty($ei->telephone_numbers)) { + $ext['TelephoneNumber'] = $this->formatTelephoneNumberList($ei->telephone_numbers); + } + + if (!empty($ei->urls)) { + $ext['Url'] = $this->formatUrlList($ei->urls); + } + + $out[] = $ext; + } + + return $out; + } + + /** + * Format a GroupMember list. + * + * @param iterable $groupMembers + * @return array> + * + * @since COmanage v5.3.0 + */ + public function formatGroupMemberList(iterable $groupMembers): array + { + $out = []; + + foreach ($groupMembers as $gm) { + $out[] = [ + 'meta' => $this->formatMeta($gm), + 'group_id' => (int)($gm->group_id ?? 0), + 'member' => isset($gm->member) ? (bool)$gm->member : null, + 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, + 'group_nesting_id' => $gm->group_nesting_id ?? null, + ]; + } + + return $out; + } + + /** + * Format a full Person profile payload. + * + * @param object $person + * @return array + * + * @since COmanage v5.3.0 + */ + public function formatPersonProfile(object $person): array + { + $out = [ + 'Person' => [ + 'meta' => $this->formatMeta($person), + 'co_id' => (int)($person->co_id ?? 0), + 'date_of_birth' => $person->date_of_birth ?? null, + 'status' => (string)($person->status ?? ''), + 'timezone' => $person->timezone ?? null, + ], + ]; + + if (!empty($person->group_members)) { + $out['GroupMember'] = $this->formatGroupMemberList($person->group_members); + } + + if (!empty($person->email_addresses)) { + $out['EmailAddress'] = $this->formatEmailAddressList($person->email_addresses); + } + + if (!empty($person->identifiers)) { + $out['Identifier'] = $this->formatIdentifierList($person->identifiers); + } + + if (!empty($person->names)) { + $out['Name'] = $this->formatNameList($person->names); + } + + if (!empty($person->urls)) { + $out['Url'] = $this->formatUrlList($person->urls); + } + + if (!empty($person->person_roles)) { + $out['PersonRole'] = $this->formatPersonRoleList($person->person_roles); + } + + if (!empty($person->external_identities)) { + $out['ExternalIdentity'] = $this->formatExternalIdentityList($person->external_identities); + } + + return $out; + } +} diff --git a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php index 36bdf94b2..b587828a3 100644 --- a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php @@ -1,7 +1,31 @@ get('Types'); - - try { - $label = (string)$Types->getTypeLabel($typeId); - } catch (\Exception $e) { - $label = null; - } - - if ($label !== null) { - $typeLabelCache[$typeId] = $label; - } - - return $label; -}; - -$formatMeta = function (object $entity): array { - $meta = []; - - if (isset($entity->id)) { - $meta['id'] = (int)$entity->id; - } - if (isset($entity->created)) { - $meta['created'] = $entity->created; - } - if (isset($entity->modified)) { - $meta['modified'] = $entity->modified; - } - if (isset($entity->deleted)) { - $meta['deleted'] = (bool)$entity->deleted; - } - if (isset($entity->revision)) { - $meta['revision'] = (int)$entity->revision; - } - if (isset($entity->actor_identifier)) { - $meta['actor_identifier'] = (string)$entity->actor_identifier; - } - - return $meta; -}; - -$formatAddressList = function (iterable $addresses) use ($formatMeta, $typeLabel): array { - $out = []; - - foreach ($addresses as $a) { - $out[] = [ - 'meta' => $formatMeta($a), - 'country' => $a->country ?? null, - 'description' => $a->description ?? null, - 'language' => $a->language ?? null, - 'locality' => $a->locality ?? null, - 'postal_code' => $a->postal_code ?? null, - 'room' => $a->room ?? null, - 'state' => $a->state ?? null, - 'street' => $a->street ?? null, - 'type' => $typeLabel($a->type_id ?? null), - ]; - } - - return $out; -}; - -$formatAdHocAttributeList = function (iterable $attrs) use ($formatMeta): array { - $out = []; - - foreach ($attrs as $aha) { - $out[] = [ - 'meta' => $formatMeta($aha), - 'tag' => $aha->tag ?? null, - 'value' => $aha->value ?? null, - ]; - } - - return $out; -}; - -$formatTelephoneNumberList = function (iterable $tels) use ($formatMeta, $typeLabel): array { - $out = []; - - foreach ($tels as $tn) { - $out[] = [ - 'meta' => $formatMeta($tn), - 'country_code' => $tn->country_code ?? null, - 'area_code' => $tn->area_code ?? null, - 'number' => $tn->number ?? null, - 'extension' => $tn->extension ?? null, - 'description' => $tn->description ?? null, - 'type' => $typeLabel($tn->type_id ?? null), - ]; - } - - return $out; -}; - -$formatEmailAddressList = function (iterable $emails) use ($formatMeta, $typeLabel): array { - $out = []; - - foreach ($emails as $ea) { - $out[] = [ - 'meta' => $formatMeta($ea), - 'mail' => $ea->mail ?? null, - 'type' => $typeLabel($ea->type_id ?? null), - 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, - ]; - } - - return $out; -}; - -$formatIdentifierList = function (iterable $identifiers) use ($formatMeta, $typeLabel): array { - $out = []; - - foreach ($identifiers as $id) { - $out[] = [ - 'meta' => $formatMeta($id), - 'identifier' => $id->identifier ?? null, - 'login' => isset($id->login) ? (bool)$id->login : null, - 'status' => $id->status ?? null, - 'type' => $typeLabel($id->type_id ?? null), - ]; - } - - return $out; -}; - -$formatNameList = function (iterable $names) use ($formatMeta, $typeLabel): array { - $out = []; - - foreach ($names as $n) { - $out[] = [ - 'meta' => $formatMeta($n), - 'family' => $n->family ?? null, - 'formatted' => $n->display_name ?? null, - 'given' => $n->given ?? null, - 'language' => $n->language ?? null, - 'middle' => $n->middle ?? null, - 'prefix' => $n->honorific ?? null, - 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, - 'suffix' => $n->suffix ?? null, - 'type' => $typeLabel($n->type_id ?? null), - ]; - } - - return $out; -}; - -$formatUrlList = function (iterable $urls) use ($formatMeta, $typeLabel): array { - $out = []; - - foreach ($urls as $u) { - $out[] = [ - 'meta' => $formatMeta($u), - 'description' => $u->description ?? null, - 'url' => $u->url ?? null, - 'type' => $typeLabel($u->type_id ?? null), - ]; - } - - return $out; -}; - -$formatPersonRoleList = function (iterable $personRoles) use ( - $formatMeta, - $formatAddressList, - $formatAdHocAttributeList, - $formatTelephoneNumberList -): array { - $out = []; - - foreach ($personRoles as $pr) { - $role = [ - 'meta' => $formatMeta($pr), - 'cou_id' => $pr->cou_id ?? null, - 'title' => $pr->title ?? null, - 'o' => $pr->organization ?? null, - 'ou' => $pr->department ?? null, - 'valid_from' => $pr->valid_from ?? null, - 'valid_through' => $pr->valid_through ?? null, - 'status' => $pr->status ?? null, - 'sponsor_person_id' => $pr->sponsor_person_id ?? null, - 'affiliation' => $pr->affiliation_type->value ?? null, - 'ordr' => $pr->ordr ?? null, - ]; - - // Include role-level MVEAs whenever the association is present on the entity - if (isset($pr->addresses)) { - $role['Address'] = !empty($pr->addresses) ? $formatAddressList($pr->addresses) : []; - } - if (isset($pr->ad_hoc_attributes)) { - $role['AdHocAttribute'] = !empty($pr->ad_hoc_attributes) ? $formatAdHocAttributeList($pr->ad_hoc_attributes) : []; - } - if (isset($pr->telephone_numbers)) { - $role['TelephoneNumber'] = !empty($pr->telephone_numbers) ? $formatTelephoneNumberList($pr->telephone_numbers) : []; - } - - $out[] = $role; - } - - return $out; -}; - -$formatExternalIdentityRoleList = function (iterable $externalIdentityRoles) use ( - $formatMeta, - $formatAddressList, - $formatAdHocAttributeList, - $formatTelephoneNumberList -): array { - $out = []; - - foreach ($externalIdentityRoles as $eir) { - $role = [ - 'meta' => $formatMeta($eir), - 'title' => $eir->title ?? null, - 'o' => $eir->organization ?? null, - 'ou' => $eir->department ?? null, - 'valid_from' => $eir->valid_from ?? null, - 'valid_through' => $eir->valid_through ?? null, - 'status' => $eir->status ?? null, - 'affiliation' => $eir->affiliation_type->value ?? null, - 'ordr' => $eir->ordr ?? null, - ]; - - // Include EIR-level MVEAs whenever the association is present on the entity - if (isset($eir->addresses)) { - $role['Address'] = !empty($eir->addresses) ? $formatAddressList($eir->addresses) : []; - } - if (isset($eir->ad_hoc_attributes)) { - $role['AdHocAttribute'] = !empty($eir->ad_hoc_attributes) ? $formatAdHocAttributeList($eir->ad_hoc_attributes) : []; - } - if (isset($eir->telephone_numbers)) { - $role['TelephoneNumber'] = !empty($eir->telephone_numbers) ? $formatTelephoneNumberList($eir->telephone_numbers) : []; - } - - $out[] = $role; - } - - return $out; -}; - -$formatExternalIdentityList = function (iterable $externalIdentities) use ( - $formatMeta, - $formatAddressList, - $formatAdHocAttributeList, - $formatTelephoneNumberList, - $formatEmailAddressList, - $formatIdentifierList, - $formatNameList, - $formatUrlList, - $formatExternalIdentityRoleList -): array { - $out = []; - - foreach ($externalIdentities as $ei) { - $ext = [ - 'meta' => $formatMeta($ei), - 'co_id' => $ei->co_id ?? null, - 'title' => $ei->title ?? null, - 'o' => $ei->organization ?? null, - 'ou' => $ei->department ?? null, - 'valid_from' => $ei->valid_from ?? null, - 'valid_through' => $ei->valid_through ?? null, - 'status' => $ei->status ?? null, - 'affiliation' => null, - 'date_of_birth' => $ei->date_of_birth ?? null, - ]; - - // Cake hasMany ExternalIdentityRoles => entity field external_identity_roles - if (isset($ei->external_identity_roles)) { - $ext['ExternalIdentityRole'] = !empty($ei->external_identity_roles) - ? $formatExternalIdentityRoleList($ei->external_identity_roles) - : []; - } - - if (!empty($ei->addresses)) { - $ext['Address'] = $formatAddressList($ei->addresses); - } - - if (!empty($ei->ad_hoc_attributes)) { - $ext['AdHocAttribute'] = $formatAdHocAttributeList($ei->ad_hoc_attributes); - } - - if (!empty($ei->email_addresses)) { - $ext['EmailAddress'] = $formatEmailAddressList($ei->email_addresses); - } - - if (!empty($ei->identifiers)) { - $ext['Identifier'] = $formatIdentifierList($ei->identifiers); - } - - if (!empty($ei->names)) { - $ext['Name'] = $formatNameList($ei->names); - } - - if (!empty($ei->telephone_numbers)) { - $ext['TelephoneNumber'] = $formatTelephoneNumberList($ei->telephone_numbers); - } - - if (!empty($ei->urls)) { - $ext['Url'] = $formatUrlList($ei->urls); - } - - $out[] = $ext; - } - - return $out; -}; - -$formatGroupMemberList = function (iterable $groupMembers) use ($formatMeta): array { - $out = []; - - foreach ($groupMembers as $gm) { - $out[] = [ - 'meta' => $formatMeta($gm), - 'group_id' => (int)($gm->group_id ?? 0), - 'member' => isset($gm->member) ? (bool)$gm->member : null, - 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, - 'group_nesting_id' => $gm->group_nesting_id ?? null, - ]; - } - - return $out; -}; - -$formatPersonProfile = function (object $person) use ( - $formatMeta, - $formatGroupMemberList, - $formatEmailAddressList, - $formatIdentifierList, - $formatNameList, - $formatUrlList, - $formatPersonRoleList, - $formatExternalIdentityList -): array { - $out = [ - 'Person' => [ - 'meta' => $formatMeta($person), - 'co_id' => (int)($person->co_id ?? 0), - 'date_of_birth' => $person->date_of_birth ?? null, - 'status' => (string)($person->status ?? ''), - 'timezone' => $person->timezone ?? null, - ], - ]; - - if (!empty($person->group_members)) { - $out['GroupMember'] = $formatGroupMemberList($person->group_members); - } - - if (!empty($person->email_addresses)) { - $out['EmailAddress'] = $formatEmailAddressList($person->email_addresses); - } - - if (!empty($person->identifiers)) { - $out['Identifier'] = $formatIdentifierList($person->identifiers); - } - - if (!empty($person->names)) { - $out['Name'] = $formatNameList($person->names); - } - - if (!empty($person->urls)) { - $out['Url'] = $formatUrlList($person->urls); - } - - if (!empty($person->person_roles)) { - $out['PersonRole'] = $formatPersonRoleList($person->person_roles); - } - - if (!empty($person->external_identities)) { - $out['ExternalIdentity'] = $formatExternalIdentityList($person->external_identities); - } - - return $out; -}; - if (!empty($vv_people) && is_iterable($vv_people)) { $page = (int)($vv_page ?? 1); $limit = (int)($vv_limit ?? 100); @@ -407,7 +47,7 @@ $items = []; foreach ($vv_people as $p) { if ($p !== null) { - $items[] = $formatPersonProfile($p); + $items[] = $this->PersonProfile->formatPersonProfile($p); } } @@ -436,7 +76,7 @@ return; } -$out = $formatPersonProfile($person); +$out = $this->PersonProfile->formatPersonProfile($person); $this->set('vv_results', $out); echo json_encode($out, JSON_UNESCAPED_SLASHES); From 954598719be578b830f3734be85671f2f31bda77 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 7 Jul 2026 15:17:33 +0000 Subject: [PATCH 10/16] upsert improvements and initial testing --- .../PersonProfileApiV2Controller.php | 106 ++- .../Lib/Traits/PersonProfileUpsertTrait.php | 605 ++++++++++++++++++ .../CoreApi/src/Lib/Utils/MessageFilter.php | 2 +- 3 files changed, 686 insertions(+), 27 deletions(-) create mode 100644 app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index a35dc799b..14afe71e5 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -34,14 +34,14 @@ use Cake\Http\Exception\BadRequestException; use Cake\ORM\Table; use Cake\ORM\TableRegistry; -use Cake\View\View; use CoreApi\Lib\Enum\ResponseTypesEnum; use CoreApi\Lib\Traits\ApiPaginationTrait; -use CoreApi\Lib\Util\MessageFilter; +use CoreApi\Lib\Traits\PersonProfileUpsertTrait; class PersonProfileApiV2Controller extends StandardApiController { use ApiPaginationTrait; + use PersonProfileUpsertTrait; /** * Loaded PersonProfiles configuration for the current API (person_profiles row). @@ -359,9 +359,16 @@ public function put(string $coid, string $identifier): void /** * Handle a create or update request. * - * Note: this currently filters metadata only for the primary "Person" record. - * Related model write support should be added by extracting and filtering those - * blocks separately (using MessageFilter) before marshalling to entities. + * Semantics: + * - Request payload is the full envelope (Person + top-level related arrays) + * - All meta is ignored except meta.id + * - ExternalIdentity is ignored entirely (read-only) + * - For each related block present in the payload, the posted list becomes the new truth: + * update if id present, insert if not, delete any existing records not present. + * + * For updates: + * - The URL identifier determines which Person is being updated (via configured identifier type) + * - If payload.Person.meta.id is present, it must match the resolved Person.id (DB id), else 400. * * @since COmanage Registry v5.3.0 * @param string $coid CO ID @@ -378,29 +385,34 @@ public function upsert(string $coid, ?string $identifier = null): void throw new BadRequestException(__d('error', 'invalid.request')); } - // Accept either { "Person": {...} } (spec), { "person": {...} } (legacy), or a flat payload. - $personDataRaw = $payload['Person'] ?? $payload['person'] ?? $payload; - - if (!is_array($personDataRaw)) { + if (empty($payload['Person']) || !is_array($payload['Person'])) { throw new BadRequestException(__d('error', 'invalid.request')); } - // Filter inbound metadata for the primary record - $personData = MessageFilter::filterMetadataInbound($personDataRaw, 'Person'); + // Ignore ExternalIdentity entirely (read-only), whether present or not. + unset($payload['ExternalIdentity']); $results = []; $resultCode = 400; try { - if ($identifier === null) { - $entity = $People->newEntity($personData); - $entity->co_id = (int)$coid; + $coId = (int)$coid; + + $result = $People->getConnection()->transactional(function () use ($payload, $identifier, $coId, $People, $Identifiers): array { + if ($identifier === null) { + $personData = $this->normalizePersonInbound($payload['Person'], $coId); + + $person = $People->newEntity($personData); + $People->saveOrFail($person); - $People->saveOrFail($entity); + $this->applyPersonProfileAssociations($person->id, $coId, $payload); + + return [ + 'status' => 201, + 'body' => ['id' => (int)$person->id], + ]; + } - $resultCode = 201; - $results = ['id' => $entity->id]; - } else { $typeId = $this->getConfiguredIdentifierTypeId(); if ($typeId === null) { throw new BadRequestException(__d('error', 'invalid.request')); @@ -408,14 +420,42 @@ public function upsert(string $coid, ?string $identifier = null): void $personId = $Identifiers->lookupPerson($typeId, $identifier); - $entity = $People->findById($personId)->firstOrFail(); - $entity = $People->patchEntity($entity, $personData); - - $People->saveOrFail($entity); + // If the payload includes Person.meta.id, it must match the resolved Person.id. + $payloadPersonId = $this->extractPersonIdFromPayload($payload['Person']); + if ($payloadPersonId !== null && $payloadPersonId !== $personId) { + throw new BadRequestException(__d('error', 'invalid.request')); + } - $resultCode = 200; - $results = ['id' => $entity->id]; - } + $person = $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->contain([ + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Urls', + 'GroupMembers', + 'PersonRoles' => [ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers', + ], + ]) + ->firstOrFail(); + + $personData = $this->normalizePersonInbound($payload['Person'], $coId); + $person = $People->patchEntity($person, $personData); + $People->saveOrFail($person); + + $this->applyPersonProfileAssociations($person->id, $coId, $payload, $person); + + return [ + 'status' => 200, + 'body' => ['id' => (int)$person->id], + ]; + }); + + $resultCode = (int)$result['status']; + $results = (array)$result['body']; } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { $resultCode = 404; $results = ['error' => $e->getMessage()]; @@ -508,6 +548,20 @@ protected function getIdentifiersTable(): Table return TableRegistry::getTableLocator()->get('Identifiers'); } + /** + * Obtain the Types table. + * + * @since COmanage Registry v5.3.0 + * @return \App\Model\Table\TypesTable + */ + protected function getTypesTable(): \App\Model\Table\TypesTable + { + /** @var \App\Model\Table\TypesTable $Types */ + $Types = TableRegistry::getTableLocator()->get('Types'); + + return $Types; + } + /** * Return the configured identifier type id (or null if not configured). * @@ -526,4 +580,4 @@ protected function getConfiguredIdentifierTypeId(): ?int return ($typeId !== null) ? (int)$typeId : null; } -} +} \ No newline at end of file diff --git a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php new file mode 100644 index 000000000..25ca96b03 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -0,0 +1,605 @@ + 'id'), that exact existing record is patched and saved. + * - If an 'id' is provided but does not belong to the current Person / parent set, the request fails (400). + * - Create: if an incoming element has no 'meta.id', a new record is created and linked to the parent. + * - Delete missing: any existing record in that set whose 'id' is not present in the inbound list is deleted. + * + * For association blocks that are omitted from the payload: + * - No reconciliation occurs (the existing DB set is left unchanged). + * + * ## Nested role MVEAs (PersonRole children) + * - 'PersonRole' itself is reconciled using the same replace-set rules above. + * - For each incoming 'PersonRole', nested blocks ('Address', 'AdHocAttribute', 'TelephoneNumber') are reconciled + * against that specific role, using the same update/create/delete-missing rules, but only when the nested key + * is present under that role in the payload. + * + * ## Type label mapping + * - Where supported, inbound string labels like 'type' / 'affiliation' are mapped to '*_type_id' via the Types table. + * (eg 'EmailAddress.type' -> 'email_addresses.type_id', 'PersonRole.affiliation' -> 'person_roles.affiliation_type_id'). + * + * ## Date/time normalization + * - Inbound 'valid_from' / 'valid_through' strings (where present) are normalized to 'Y-m-d H:i:s' before save; + * empty string becomes 'null'. + */ + +trait PersonProfileUpsertTrait +{ + /** + * Normalize inbound Person data: + * - ignore all metadata except meta.id (handled by MessageFilter) + * - enforce co_id from the route + * + * @param array $personRaw + * @param int $coId + * @return array + * @since COmanage Registry v5.3.0 + */ + protected function normalizePersonInbound(array $personRaw, int $coId): array + { + $person = MessageFilter::filterMetadataInbound($personRaw, 'Person'); + $person['co_id'] = $coId; + + return $person; + } + + /** + * Extract Person.id from payload Person.meta.id, if present and valid. + * + * @param array $personRaw + * @return int|null + * @since COmanage Registry v5.3.0 + */ + protected function extractPersonIdFromPayload(array $personRaw): ?int + { + if (!empty($personRaw['meta']) && is_array($personRaw['meta']) && array_key_exists('id', $personRaw['meta'])) { + $id = $personRaw['meta']['id']; + + if (is_int($id)) { + return $id; + } + + if (is_string($id) && ctype_digit($id)) { + return (int)$id; + } + } + + return null; + } + + /** + * Apply related objects (hasMany + nested role MVEAs) as "replace these sets": + * - meta.* ignored except meta.id + * - if incoming record has id => update that record + * - if incoming record has no id => create new record + * - if an existing record id is not present in incoming list => delete it + * + * NOTE: associations are only reconciled when the corresponding top-level key is present. + * + * @param int $personId + * @param int $coId + * @param array $payload + * @param object|null $loadedPerson Optional preloaded Person (with contain) + * @return void + * @since COmanage Registry v5.3.0 + */ + protected function applyPersonProfileAssociations(int $personId, int $coId, array $payload, ?object $loadedPerson = null): void + { + /** @var \App\Model\Table\PeopleTable $People */ + $People = $this->getPeopleTable(); + + /** @var \App\Model\Table\TypesTable $Types */ + $Types = $this->getTypesTable(); + + if ($loadedPerson === null) { + $loadedPerson = $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->contain([ + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Urls', + 'GroupMembers', + 'PersonRoles' => [ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers', + ], + ]) + ->firstOrFail(); + } + + // EmailAddress + if (array_key_exists('EmailAddress', $payload)) { + $incoming = is_array($payload['EmailAddress']) ? $payload['EmailAddress'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('EmailAddresses')->getTarget(), + existing: $loadedPerson->email_addresses ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'EmailAddresses.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // Identifier + if (array_key_exists('Identifier', $payload)) { + $incoming = is_array($payload['Identifier']) ? $payload['Identifier'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('Identifiers')->getTarget(), + existing: $loadedPerson->identifiers ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Identifiers.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // Name (payload uses: formatted/prefix ; DB uses: display_name/honorific) + if (array_key_exists('Name', $payload)) { + $incoming = is_array($payload['Name']) ? $payload['Name'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('Names')->getTarget(), + existing: $loadedPerson->names ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Names.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [ + 'formatted' => 'display_name', + 'prefix' => 'honorific', + ] + ); + } + + // Url + if (array_key_exists('Url', $payload)) { + $incoming = is_array($payload['Url']) ? $payload['Url'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('Urls')->getTarget(), + existing: $loadedPerson->urls ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Urls.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // GroupMember (no type mapping; group_id is treated as a normal inbound field here) + if (array_key_exists('GroupMember', $payload)) { + $incoming = is_array($payload['GroupMember']) ? $payload['GroupMember'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('GroupMembers')->getTarget(), + existing: $loadedPerson->group_members ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: null, + fieldMap: [] + ); + } + + // PersonRole + nested MVEAs + if (array_key_exists('PersonRole', $payload)) { + $incomingRoles = is_array($payload['PersonRole']) ? $payload['PersonRole'] : []; + + $roleIdMap = $this->reconcileHasMany( + table: $People->associations()->get('PersonRoles')->getTarget(), + existing: $loadedPerson->person_roles ?? [], + incoming: $incomingRoles, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'affiliation', + 'attribute' => 'PersonRoles.affiliation_type', + 'targetField' => 'affiliation_type_id', + 'typesTable' => $Types, + ], + fieldMap: [ + 'o' => 'organization', + 'ou' => 'department', + ], + returnsNewIdMap: true + ); + + /** @var \App\Model\Table\PersonRolesTable $PersonRoles */ + $PersonRoles = TableRegistry::getTableLocator()->get('PersonRoles'); + + $existingRolesById = []; + foreach (($loadedPerson->person_roles ?? []) as $er) { + if (!empty($er->id)) { + $existingRolesById[(int)$er->id] = $er; + } + } + + foreach ($incomingRoles as $idx => $incomingRoleRaw) { + if (!is_array($incomingRoleRaw)) { + continue; + } + + $incomingRole = $this->extractInboundId($incomingRoleRaw, 'PersonRole'); + $roleId = $incomingRole['id'] ?? null; + + if (empty($roleId) && isset($roleIdMap[$idx])) { + $roleId = (int)$roleIdMap[$idx]; + } + + if (empty($roleId)) { + continue; + } + + $existingRoleEntity = $existingRolesById[(int)$roleId] ?? null; + + // Address under PersonRole + if (array_key_exists('Address', $incomingRoleRaw)) { + $incomingAddresses = is_array($incomingRoleRaw['Address']) ? $incomingRoleRaw['Address'] : []; + $this->reconcileHasMany( + table: $PersonRoles->associations()->get('Addresses')->getTarget(), + existing: $existingRoleEntity->addresses ?? [], + incoming: $incomingAddresses, + parentFk: 'person_role_id', + parentId: (int)$roleId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Addresses.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // AdHocAttribute under PersonRole + if (array_key_exists('AdHocAttribute', $incomingRoleRaw)) { + $incomingAttrs = is_array($incomingRoleRaw['AdHocAttribute']) ? $incomingRoleRaw['AdHocAttribute'] : []; + $this->reconcileHasMany( + table: $PersonRoles->associations()->get('AdHocAttributes')->getTarget(), + existing: $existingRoleEntity->ad_hoc_attributes ?? [], + incoming: $incomingAttrs, + parentFk: 'person_role_id', + parentId: (int)$roleId, + coId: $coId, + typeSpec: null, + fieldMap: [] + ); + } + + // TelephoneNumber under PersonRole + if (array_key_exists('TelephoneNumber', $incomingRoleRaw)) { + $incomingTels = is_array($incomingRoleRaw['TelephoneNumber']) ? $incomingRoleRaw['TelephoneNumber'] : []; + $this->reconcileHasMany( + table: $PersonRoles->associations()->get('TelephoneNumbers')->getTarget(), + existing: $existingRoleEntity->telephone_numbers ?? [], + incoming: $incomingTels, + parentFk: 'person_role_id', + parentId: (int)$roleId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'TelephoneNumbers.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + } + } + } + + /** + * Reconcile a hasMany set using "id-present => update, id-absent => insert, missing => delete". + * + * @param \Cake\ORM\Table $table + * @param iterable $existing + * @param array $incoming + * @param string $parentFk + * @param int $parentId + * @param int $coId + * @param array|null $typeSpec + * @param array $fieldMap + * @param bool $returnsNewIdMap + * @return array Map of incoming index => newly created id (only when $returnsNewIdMap=true) + * @since COmanage Registry v5.3.0 + */ + protected function reconcileHasMany( + \Cake\ORM\Table $table, + iterable $existing, + array $incoming, + string $parentFk, + int $parentId, + int $coId, + ?array $typeSpec, + array $fieldMap, + bool $returnsNewIdMap = false + ): array { + $existingById = []; + foreach ($existing as $e) { + if (!empty($e->id)) { + $existingById[(int)$e->id] = $e; + } + } + + $seenIds = []; + $newIdMap = []; + + foreach ($incoming as $idx => $raw) { + if (!is_array($raw)) { + continue; + } + + $data = $this->extractInboundId($raw, $table->getAlias()); + $data = MessageFilter::filterMetadataInbound($data, $table->getAlias()); + + // Apply field mapping (API field => DB field) + foreach ($fieldMap as $apiField => $dbField) { + if (array_key_exists($apiField, $raw)) { + $data[$dbField] = $raw[$apiField]; + } + } + + // Type mapping (API "type"/etc label => type_id) + if ($typeSpec !== null) { + $apiTypeField = (string)$typeSpec['field']; + $attribute = (string)$typeSpec['attribute']; + $targetField = (string)$typeSpec['targetField']; + + if (array_key_exists($apiTypeField, $raw) && is_string($raw[$apiTypeField]) && $raw[$apiTypeField] !== '') { + /** @var \App\Model\Table\TypesTable $Types */ + $Types = $typeSpec['typesTable']; + + $data[$targetField] = $Types->getTypeId($coId, $attribute, $raw[$apiTypeField]); + } + } + + $data = $this->normalizeInboundDateTimes($data); + + $data[$parentFk] = $parentId; + + $id = $data['id'] ?? null; + + try { + if (!empty($id)) { + $id = (int)$id; + if (!isset($existingById[$id])) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $entity = $existingById[$id]; + unset($data['id']); // avoid primary key reassignment + $entity = $table->patchEntity($entity, $data); + $table->saveOrFail($entity); + + $seenIds[$id] = true; + } else { + $entity = $table->newEntity($data); + $table->saveOrFail($entity); + + if ($returnsNewIdMap) { + $newIdMap[(int)$idx] = (int)$entity->id; + } + } + } catch (PersistenceFailedException $e) { + $errors = $e->getEntity()->getErrors(); + $flat = []; + + $walk = function (array $node, string $prefix = '') use (&$walk, &$flat): void { + foreach ($node as $k => $v) { + $key = $prefix === '' ? (string)$k : ($prefix . '.' . (string)$k); + if (is_array($v)) { + $isLeaf = true; + foreach ($v as $vv) { + if (is_array($vv)) { + $isLeaf = false; + break; + } + } + + if ($isLeaf) { + foreach ($v as $msg) { + if ($msg !== null && $msg !== '') { + $flat[] = $key . ': "' . (string)$msg . '"'; + } + } + } else { + $walk($v, $key); + } + } elseif ($v !== null && $v !== '') { + $flat[] = $key . ': "' . (string)$v . '"'; + } + } + }; + + if (is_array($errors) && !empty($errors)) { + $walk($errors); + } + + $model = $table->getAlias(); + $op = !empty($id) ? 'update' : 'create'; + $detail = !empty($flat) ? implode(', ', $flat) : $e->getMessage(); + + throw new BadRequestException("Invalid {$model} at index {$idx} during {$op}: {$detail}"); + } + } + + // Delete any existing records not present in incoming set + foreach ($existingById as $eid => $entity) { + if (!isset($seenIds[(int)$eid])) { + $table->deleteOrFail($entity); + } + } + + return $newIdMap; + } + + /** + * Normalize inbound date and time fields ('valid_from', 'valid_through') to a standard format. + * Converts date-time strings to 'Y-m-d H:i:s' format or null if invalid or empty. + * + * @param array $data Input array containing potential date-time fields. + * @return array The normalized array with standardized date-time fields. + * @since COmanage Registry v5.3.0 + */ + protected function normalizeInboundDateTimes(array $data): array + { + foreach (['valid_from', 'valid_through'] as $k) { + if (!array_key_exists($k, $data)) { + continue; + } + + $v = $data[$k]; + + if ($v === null || $v === '') { + $data[$k] = null; + continue; + } + + if (!is_string($v)) { + continue; + } + + try { + $dt = new \DateTimeImmutable($v); + $data[$k] = $dt->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + } + } + + return $data; + } + + /** + * Extracts meta.id into top-level id if present (without keeping other meta fields). + * + * @param array $raw + * @param string $modelName + * @return array + * @since COmanage Registry v5.3.0 + */ + protected function extractInboundId(array $raw, string $modelName): array + { + if (!empty($raw['meta']) && is_array($raw['meta']) && array_key_exists('id', $raw['meta'])) { + $raw['id'] = $raw['meta']['id']; + } + + return $raw; + } + + /** + * Obtain the People table. + * + * @return Table + * @since COmanage Registry v5.3.0 + */ + abstract protected function getPeopleTable(): Table; + + /** + * Obtain the Types table. + * + * @return \App\Model\Table\TypesTable + * @since COmanage Registry v5.3.0 + */ + abstract protected function getTypesTable(): \App\Model\Table\TypesTable; +} diff --git a/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php index 5133be063..1b7fda41e 100644 --- a/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php +++ b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php @@ -27,7 +27,7 @@ declare(strict_types=1); -namespace CoreApi\Lib\Util; +namespace CoreApi\Lib\Utils; use Cake\Utility\Hash; use Cake\Utility\Inflector; From 6ffe102c29ead88ef6d31dd764d66e76f67b7980 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 8 Jul 2026 09:39:01 +0000 Subject: [PATCH 11/16] Initial Create. Improved full profile json objects. Improved data metadata filtering and associated classes rendering. --- app/plugins/CoreApi/config/openapi yaml | 463 ++++++++------- .../Lib/Traits/PersonProfileUpsertTrait.php | 31 ++ .../CoreApi/src/Lib/Utils/MessageFilter.php | 102 ++-- .../src/Model/Table/PersonProfilesTable.php | 46 +- .../src/View/Helper/PersonProfileHelper.php | 525 ++++++------------ .../json/person_profile.php | 1 + 6 files changed, 533 insertions(+), 635 deletions(-) diff --git a/app/plugins/CoreApi/config/openapi yaml b/app/plugins/CoreApi/config/openapi yaml index a063b5d46..32e23fa86 100644 --- a/app/plugins/CoreApi/config/openapi yaml +++ b/app/plugins/CoreApi/config/openapi yaml @@ -1,4 +1,5 @@ openapi: 3.0.3 + info: title: COmanage Registry Core API (v5) - Person Profiles API v2 description: | @@ -9,11 +10,19 @@ info: /api/person-profiles/{coid}/v2/person /api/person-profiles/{coid}/v2/person/{identifier} - Query parameters **identifier**, **direction**, **limit**, and **page** are supported + Query parameters 'identifier', 'direction', 'limit', and 'page' are supported for compatibility with the v4 Core API People endpoint. External identities are returned on GET (read-only) but are not supported for editing - via POST/PUT. + via POST/PUT (they are ignored if provided). + + Write semantics (POST/PUT): + - For each association block present in the payload, the inbound list replaces the existing set: + - inbound element with 'meta.id' updates that existing row + - inbound element without 'meta.id' creates a new row + - existing rows missing from the inbound list are deleted + - If an association block key is omitted from the payload, that association is not reconciled (left unchanged). + - To delete all rows for an association, include the association key with an empty list (eg 'EmailAddress': []). contact: name: COmanage Project url: https://spaces.at.internet2.edu/display/COmanage/About+the+COmanage+Project @@ -22,6 +31,7 @@ info: name: APACHE LICENSE, VERSION 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html version: 1.0.0 + servers: - url: https://localhost/registry description: | @@ -38,10 +48,10 @@ paths: tags: [PersonProfiles] summary: Retrieve one or more People and related objects description: | - Use the **identifier** query parameter to retrieve a single Person Profile (by identifier value). + Use the 'identifier' query parameter to retrieve a single Person Profile (by identifier value). With no query parameters, retrieve all People in the CO. - Use **direction**, **limit**, and **page** to control ordering and pagination. + Use 'direction', 'limit', and 'page' to control ordering and pagination. Note: The detailed semantics of identifier resolution are deployment/configuration dependent. operationId: getPersonProfiles @@ -197,6 +207,10 @@ paths: responses: "200": description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedId" "400": description: Bad Request "401": @@ -227,8 +241,8 @@ paths: schema: type: string responses: - "200": - description: Deleted + "204": + description: No Content (deleted) "401": description: Unauthorized "404": @@ -243,12 +257,10 @@ components: scheme: basic schemas: - Meta: + MetaRead: type: object description: | - Metadata about objects returned when reading (GET). - - Most metadata is read-only and will be ignored on write operations (POST/PUT). + Metadata returned on read (GET). properties: id: description: COmanage identifier for this object @@ -280,12 +292,25 @@ components: required: [id] additionalProperties: false - Person: + MetaWrite: type: object - description: Person object (v5 equivalent of v4 CoPerson). + description: | + Metadata accepted on write (POST/PUT). + + Only 'id' is meaningful for write semantics (record correlation). Other metadata fields, if provided, + are ignored by the server. + properties: + id: + description: COmanage identifier for this object (used to update an existing record) + type: integer + additionalProperties: false + + PersonRead: + type: object + description: Person object (read form). properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaRead" co_id: description: CO ID for this Person type: integer @@ -294,13 +319,40 @@ components: description: Person date of birth type: string format: date + nullable: true + status: + description: Person status + type: string + timezone: + description: Preferred timezone of this Person, for UI purposes + type: string + nullable: true + required: [meta, co_id, status] + additionalProperties: false + + PersonWrite: + type: object + description: | + Person object (write form). + + 'co_id' is forced from the route and cannot be set via the payload. + If 'meta.id' is provided on update, it must match the resolved Person id. + properties: + meta: + $ref: "#/components/schemas/MetaWrite" + date_of_birth: + description: Person date of birth + type: string + format: date + nullable: true status: description: Person status type: string timezone: description: Preferred timezone of this Person, for UI purposes type: string - required: [co_id, status] + nullable: true + required: [status] additionalProperties: false Name: @@ -310,35 +362,43 @@ components: description: Name for a Person properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" family: description: The family or surname type: string + nullable: true formatted: description: The fully formatted name type: string + nullable: true given: description: The given or first name type: string + nullable: true language: description: The language encoding for this Name type: string + nullable: true middle: description: The middle name type: string + nullable: true prefix: description: The honorific or prefix for the Name type: string + nullable: true primary_name: description: Whether this is the primary Name type: boolean + nullable: true suffix: description: The suffix for this Name type: string + nullable: true type: description: The type of Name type: string - required: [given] + nullable: true additionalProperties: false EmailAddress: @@ -348,18 +408,20 @@ components: description: Email address for a Person properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" mail: description: An email address for this person type: string format: email + nullable: true type: description: The type of Email Address type: string + nullable: true verified: description: Whether this Email Address has been verified type: boolean - required: [mail] + nullable: true additionalProperties: false Identifier: @@ -369,20 +431,23 @@ components: description: Identifier for a Person properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" identifier: description: An identifier for the person type: string + nullable: true login: description: Whether this Identifier can be used to login to Registry type: boolean + nullable: true status: description: Identifier status type: string + nullable: true type: description: The type of Identifier type: string - required: [identifier] + nullable: true additionalProperties: false Url: @@ -392,18 +457,20 @@ components: description: URL for a Person properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" description: description: Description of this URL type: string + nullable: true url: description: A URL type: string format: uri + nullable: true type: description: The type of URL type: string - required: [url] + nullable: true additionalProperties: false GroupMember: @@ -413,22 +480,24 @@ components: description: Membership of Person in a Group properties: meta: - $ref: "#/components/schemas/Meta" - co_group_id: + $ref: "#/components/schemas/MetaWrite" + group_id: description: Group ID for this membership type: integer - readOnly: true member: description: If this Person is a member of this group type: boolean + nullable: true owner: description: If this Person is an owner of this group type: boolean - co_group_nesting_id: + nullable: true + group_nesting_id: description: Group nesting that created this membership, if set type: integer + nullable: true readOnly: true - required: [co_group_id] + required: [group_id] additionalProperties: false Address: @@ -438,36 +507,43 @@ components: description: Postal address properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" country: description: Country for this Address type: string + nullable: true description: description: Description of this Address type: string + nullable: true language: description: Language encoding of this Address type: string + nullable: true locality: - description: | - Locality (eg: city) of this Address + description: Locality (eg: city) of this Address type: string + nullable: true postal_code: description: Postal code of this Address type: string + nullable: true room: description: Room associated with this Address type: string + nullable: true state: description: State of this Address type: string + nullable: true street: description: Street of this Address type: string + nullable: true type: description: Type of this Address type: string - required: [] + nullable: true additionalProperties: false AdHocAttribute: @@ -477,13 +553,15 @@ components: description: An ad-hoc attribute properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" tag: description: Tag for this Ad Hoc Attribute type: string + nullable: true value: description: Value of this Ad Hoc Attribute type: string + nullable: true required: [tag] additionalProperties: false @@ -494,68 +572,82 @@ components: description: Telephone number properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaWrite" country_code: description: Country code for this Telephone Number type: string + nullable: true area_code: description: Area code for this Telephone Number type: string + nullable: true number: description: Number for this Telephone Number type: string + nullable: true extension: description: Extension for this Telephone Number type: string + nullable: true description: description: Description of this Telephone Number type: string + nullable: true type: description: Type of this Telephone Number type: string + nullable: true required: [number] additionalProperties: false - PersonRole: + PersonRoleRead: type: array items: type: object - description: Role for a Person (v5 equivalent of v4 CoPersonRole) + description: Role for a Person (read form) properties: meta: - $ref: "#/components/schemas/Meta" + $ref: "#/components/schemas/MetaRead" cou_id: description: COU for this Role type: integer + nullable: true title: description: Title for this Role type: string + nullable: true o: description: Organization for this Role type: string + nullable: true ou: description: Department for this Role type: string + nullable: true valid_from: description: Valid from time for this Role type: string - format: date-time + nullable: true valid_through: description: Valid through time for this Role type: string - format: date-time + nullable: true status: description: Person Role status type: string + nullable: true sponsor_person_id: description: Sponsor Person ID for this Role type: integer + nullable: true affiliation: description: Person Role affiliation type: string + nullable: true ordr: description: Order of this Role, relative to other roles for this person type: integer + nullable: true Address: $ref: "#/components/schemas/Address" AdHocAttribute: @@ -565,187 +657,186 @@ components: required: [affiliation, status] additionalProperties: false - # Read-only external identity (v4 OrgIdentity), returned on GET only. - ExternalIdentity: + PersonRoleWrite: type: array - description: | - External identities associated with the Person. - - Read-only in this API surface: returned on GET responses, ignored/not supported on POST/PUT. items: type: object + description: Role for a Person (write form) properties: meta: - $ref: "#/components/schemas/Meta" - co_id: - description: CO for this External Identity + $ref: "#/components/schemas/MetaWrite" + cou_id: + description: COU for this Role type: integer + nullable: true title: - description: Title for this External Identity + description: Title for this Role type: string + nullable: true o: - description: Organization for this External Identity + description: Organization for this Role type: string + nullable: true ou: - description: Department for this External Identity + description: Department for this Role type: string + nullable: true valid_from: - description: Valid from time for this External Identity + description: Valid from time for this Role type: string format: date-time + nullable: true valid_through: - description: Valid through time for this External Identity + description: Valid through time for this Role type: string format: date-time + nullable: true status: - description: External Identity status + description: Person Role status type: string + nullable: true + sponsor_person_id: + description: Sponsor Person ID for this Role + type: integer + nullable: true affiliation: - description: External Identity affiliation - type: string - date_of_birth: - description: External Identity date of birth + description: Person Role affiliation type: string - format: date + nullable: true + ordr: + description: Order of this Role, relative to other roles for this person + type: integer + nullable: true Address: $ref: "#/components/schemas/Address" AdHocAttribute: $ref: "#/components/schemas/AdHocAttribute" - EmailAddress: - $ref: "#/components/schemas/EmailAddress" - Identifier: - $ref: "#/components/schemas/Identifier" - Name: - $ref: "#/components/schemas/Name" TelephoneNumber: $ref: "#/components/schemas/TelephoneNumber" - Url: - $ref: "#/components/schemas/Url" - required: [] - additionalProperties: false - - SshKey: - type: array - items: - type: object - description: Object representing an SSH key - properties: - meta: - $ref: "#/components/schemas/Meta" - comment: - description: Comment for this SSH Key - type: string - type: - description: SSH Key type - type: string - skey: - description: SSH Key - type: string - ssh_key_authenticator_id: - description: SSH Key Authenticator ID associated with this SSH Key - type: integer - readOnly: true - required: [type, skey] + required: [affiliation, status] additionalProperties: false - Certificate: + ExternalIdentityRoleRead: type: array items: type: object - description: Certificate + description: Role associated with an External Identity (read-only) properties: meta: - $ref: "#/components/schemas/Meta" - description: - description: Description of this Certificate + $ref: "#/components/schemas/MetaRead" + title: + description: Title for this External Identity Role type: string - subject_dn: - description: Subject DN of this Certificate + nullable: true + o: + description: Organization for this External Identity Role type: string - issuer_in: - description: Issuer DN of this Certificate + nullable: true + ou: + description: Department for this External Identity Role type: string + nullable: true valid_from: - description: Valid from time for this Certificate + description: Valid from time for this External Identity Role type: string - format: date-time + nullable: true valid_through: - description: Valid through time for this Certificate + description: Valid through time for this External Identity Role type: string - format: date-time - certificate_authenticator_id: - description: Certificate Authenticator ID associated with this Certificate - type: integer - readOnly: true - required: [subject_dn] - additionalProperties: false - - Password: - type: array - items: - type: object - description: Password - properties: - meta: - $ref: "#/components/schemas/Meta" - password: - description: Password + nullable: true + status: + description: External Identity Role status type: string - password_type: - description: Password (hash) type + nullable: true + affiliation: + description: External Identity Role affiliation type: string - password_authenticator_id: - description: Password Authenticator ID associated with this Password + nullable: true + ordr: + description: Order for this External Identity Role type: integer - readOnly: true - required: [password, password_type] + nullable: true + Address: + $ref: "#/components/schemas/Address" + AdHocAttribute: + $ref: "#/components/schemas/AdHocAttribute" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" additionalProperties: false - UnixClusterAccount: + ExternalIdentityRead: type: array + description: | + External identities associated with the Person. + + Read-only in this API surface: returned on GET responses, ignored/not supported on POST/PUT. items: type: object - description: Unix Cluster Account + description: External identity (read-only) properties: meta: - $ref: "#/components/schemas/Meta" - sync_mode: - description: Sync Mode for this Unix Cluster Account - type: string - status: - description: Status for this Unix Cluster Account - type: string - username: - description: Username for this Unix Cluster Account + $ref: "#/components/schemas/MetaRead" + co_id: + description: CO for this External Identity + type: integer + nullable: true + title: + description: Title for this External Identity type: string - uid: - description: UID for this Unix Cluster Account + nullable: true + o: + description: Organization for this External Identity type: string - gecos: - description: GECOS for this Unix Cluster Account + nullable: true + ou: + description: Department for this External Identity type: string - login_shell: - description: Login shell for this Unix Cluster Account + nullable: true + valid_from: + description: Valid from time for this External Identity type: string - home_directory: - description: Home directory for this Unix Cluster Account + nullable: true + valid_through: + description: Valid through time for this External Identity type: string - primary_co_group_id: - description: Primary group for this Unix Cluster Account + nullable: true + status: + description: External Identity status type: string - valid_from: - description: Valid from time for this Unix Cluster Account + nullable: true + affiliation: + description: External Identity affiliation type: string - format: date-time - valid_through: - description: Valid through time for this Unix Cluster Account + nullable: true + date_of_birth: + description: External Identity date of birth type: string - format: date-time - unix_cluster_id: - description: Unix Cluster ID associated with this Unix Cluster Account + format: date + nullable: true + ExternalIdentityRole: + $ref: "#/components/schemas/ExternalIdentityRoleRead" + Address: + $ref: "#/components/schemas/Address" + AdHocAttribute: + $ref: "#/components/schemas/AdHocAttribute" + EmailAddress: + $ref: "#/components/schemas/EmailAddress" + Identifier: + $ref: "#/components/schemas/Identifier" + Name: + $ref: "#/components/schemas/Name" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" + Url: + $ref: "#/components/schemas/Url" + additionalProperties: false + + CreatedId: + type: object + properties: + id: type: integer - readOnly: true - required: [] + required: [id] additionalProperties: false PersonProfileMessageRead: @@ -753,29 +844,21 @@ components: description: Collection of a Person and related objects (read form) properties: Person: - $ref: "#/components/schemas/Person" + $ref: "#/components/schemas/PersonRead" GroupMember: $ref: "#/components/schemas/GroupMember" EmailAddress: $ref: "#/components/schemas/EmailAddress" - PersonRole: - $ref: "#/components/schemas/PersonRole" Identifier: $ref: "#/components/schemas/Identifier" Name: $ref: "#/components/schemas/Name" - SshKey: - $ref: "#/components/schemas/SshKey" Url: $ref: "#/components/schemas/Url" - Certificate: - $ref: "#/components/schemas/Certificate" - Password: - $ref: "#/components/schemas/Password" - UnixClusterAccount: - $ref: "#/components/schemas/UnixClusterAccount" + PersonRole: + $ref: "#/components/schemas/PersonRoleRead" ExternalIdentity: - $ref: "#/components/schemas/ExternalIdentity" + $ref: "#/components/schemas/ExternalIdentityRead" required: [Person] additionalProperties: false @@ -784,52 +867,40 @@ components: description: | Collection of a Person and related objects (write form). - Note: ExternalIdentity is not supported for editing via this API surface. + Notes: + - 'ExternalIdentity' is not supported for editing via this API surface and is ignored if provided. + - For each association block present in the payload, the inbound list replaces the existing set. + Use an empty list to delete all rows for that association. properties: Person: - $ref: "#/components/schemas/Person" + $ref: "#/components/schemas/PersonWrite" GroupMember: $ref: "#/components/schemas/GroupMember" EmailAddress: $ref: "#/components/schemas/EmailAddress" - PersonRole: - $ref: "#/components/schemas/PersonRole" Identifier: $ref: "#/components/schemas/Identifier" Name: $ref: "#/components/schemas/Name" - SshKey: - $ref: "#/components/schemas/SshKey" Url: $ref: "#/components/schemas/Url" - Certificate: - $ref: "#/components/schemas/Certificate" - Password: - $ref: "#/components/schemas/Password" - UnixClusterAccount: - $ref: "#/components/schemas/UnixClusterAccount" + PersonRole: + $ref: "#/components/schemas/PersonRoleWrite" required: [Person] additionalProperties: false - CreatedId: - type: object - properties: - id: - type: integer - required: [id] - additionalProperties: false - responses: PagedPersonProfileMessage: - description: Paged collection of PersonProfileMessage objects indexed by integer values + description: | + Paged collection of PersonProfileMessageRead objects indexed by numeric keys, plus paging metadata. + + Note: The response uses object keys '0', '1', ... for items (not a JSON array), for compatibility + with legacy paging envelopes. content: application/json: schema: type: object properties: - 0: - $ref: "#/components/schemas/PersonProfileMessageRead" - description: Person profile read response object currentPage: description: current page type: string @@ -839,7 +910,7 @@ components: description: items per page type: string readOnly: true - example: "1" + example: "100" pageCount: description: page count type: string @@ -856,8 +927,6 @@ components: readOnly: true example: "1" additionalProperties: - type: array - items: $ref: "#/components/schemas/PersonProfileMessageRead" security: diff --git a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php index 25ca96b03..8e285cf05 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -79,6 +79,10 @@ * * For association blocks that are omitted from the payload: * - No reconciliation occurs (the existing DB set is left unchanged). + * - To delete all records for an association, include the association key with an empty list (eg 'EmailAddress': []). + * - To delete some records, include only the records you want to keep; any existing records not referenced by 'meta.id' + * in the inbound list will be deleted. + * * * ## Nested role MVEAs (PersonRole children) * - 'PersonRole' itself is reconciled using the same replace-set rules above. @@ -474,6 +478,33 @@ protected function reconcileHasMany( $seenIds[$id] = true; } else { + // Special-case: for GroupMembers creates, avoid triggering a failing save + // (which may rollback nested transactions) by checking if it already exists. + if ($table->getAlias() === 'GroupMembers') { + $conditions = [ + $parentFk => $parentId, + ]; + + if (array_key_exists('group_id', $data)) { + $conditions['group_id'] = (int)$data['group_id']; + } + + // For manual memberships, group_nesting_id is typically NULL. + if (!array_key_exists('group_nesting_id', $data) || $data['group_nesting_id'] === null || $data['group_nesting_id'] === '') { + $conditions[] = function (\Cake\Database\Expression\QueryExpression $exp) { + return $exp->isNull('group_nesting_id'); + }; + } else { + $conditions['group_nesting_id'] = (int)$data['group_nesting_id']; + } + + $existingEntity = $table->find()->where($conditions)->first(); + if (!empty($existingEntity?->id)) { + $seenIds[(int)$existingEntity->id] = true; + continue; + } + } + $entity = $table->newEntity($data); $table->saveOrFail($entity); diff --git a/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php index 1b7fda41e..eedad64c8 100644 --- a/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php +++ b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php @@ -29,7 +29,6 @@ namespace CoreApi\Lib\Utils; -use Cake\Utility\Hash; use Cake\Utility\Inflector; final class MessageFilter @@ -41,8 +40,12 @@ final class MessageFilter * - skips "meta" * - skips related data (array values) * - skips known metadata/system fields + * - skips changelog foreign key for this model (eg ExternalIdentity => external_identity_id) * - copies meta.id to id (if present) * + * NOTE: This is about inbound normalization. Parent/ownership FKs are generally implied by route/context + * and should not be accepted from the client unless explicitly allowed by the caller via $extraSkipFields. + * * @param array $record * @param string $modelName * @param array $extraSkipFields @@ -53,12 +56,14 @@ public static function filterMetadataInbound(array $record, string $modelName, a { $ret = []; - // Map the model to the changelog/foreign key style (eg Person -> person_id) - $mfk = Inflector::underscore($modelName) . '_id'; + // Changelog FK for this model (eg ExternalIdentity => external_identity_id) + $changelogFk = Inflector::underscore($modelName) . '_id'; + $sourceFk = 'source_' . (Inflector::underscore($modelName) . '_id'); + $skip = array_merge( [ - // Changelog-ish / system metadata + // System/changelog metadata 'actor_identifier', 'created', 'deleted', @@ -66,21 +71,15 @@ public static function filterMetadataInbound(array $record, string $modelName, a 'modified', 'revision', - // Common linkage keys in v5 - 'co_id', - 'person_id', - 'person_role_id', - 'external_identity_id', - 'external_identity_role_id', - 'group_id', - - // Source-ish / linkage keys present in v5 schema + // API-internal linkage/config keys (clients should not set these) 'source_external_identity_role_id', 'external_identity_source_id', 'api_user_id', 'provisioning_target_id', - $mfk, + // Prevent client control of changelog chain pointer for this model + $changelogFk, + $sourceFk, ], $extraSkipFields ); @@ -112,8 +111,11 @@ public static function filterMetadataInbound(array $record, string $modelName, a /** * Filter metadata on an outbound record (recursive). * - * v5 behavior: - * - moves known metadata/system fields into meta + * Desired behavior: + * - moves system/changelog fields into meta + * - moves ONLY the changelog FK for the current model into meta + * (eg ExternalIdentity => external_identity_id; PersonRole => person_role_id) + * - keeps ownership/containment FKs (eg person_id) as first-class schema fields * - recurses into related models * * @param array $record @@ -138,11 +140,13 @@ public static function filterMetadataOutbound(array $record, ?string $modelName $newa = []; - $mfk = $modelName ? (Inflector::underscore($modelName) . '_id') : null; + // Changelog FK for the current model (when provided) + $changelogFk = $modelName ? (Inflector::underscore($modelName) . '_id') : null; + $sourceFk = $modelName ? 'source_' . (Inflector::underscore($modelName) . '_id') : null; $metaFields = array_merge( [ - // Changelog-ish / system metadata + // System/changelog metadata 'actor_identifier', 'created', 'deleted', @@ -152,25 +156,20 @@ public static function filterMetadataOutbound(array $record, ?string $modelName 'lft', 'rght', - // Common linkage keys in v5 - 'co_id', - 'person_id', - 'person_role_id', - 'external_identity_id', - 'external_identity_role_id', - 'group_id', - // Additional linkage/config keys that are typically not considered "business fields" - 'api_user_id', - 'provisioning_target_id', - 'external_identity_source_id', - 'source_external_identity_role_id', +// 'api_user_id', +// 'provisioning_target_id', +// 'external_identity_source_id', +// 'source_external_identity_role_id', ], $extraMetaFields ); - if ($mfk !== null) { - $metaFields[] = $mfk; + if ($changelogFk !== null) { + $metaFields[] = $changelogFk; + } + if ($sourceFk !== null) { + $metaFields[] = $sourceFk; } foreach ($a as $k => $v) { @@ -188,17 +187,12 @@ public static function filterMetadataOutbound(array $record, ?string $modelName continue; } - // Special-case: group_id generally treated as meta unless the model is GroupMember. - if (($modelName !== 'GroupMember' && $k === 'group_id') || in_array((string)$k, $metaFields, true)) { + if (in_array((string)$k, $metaFields, true)) { $newa['meta'][(string)$k] = $v; continue; } - // Parent keys implied by containment are skipped (MVPA ownership FKs in v5) - if (in_array((string)$k, ['person_id', 'person_role_id', 'external_identity_id', 'external_identity_role_id', 'group_id'], true)) { - continue; - } - + // Everything else (including ownership FKs like person_id) remains first-class $newa[(string)$k] = $v; } @@ -207,34 +201,4 @@ public static function filterMetadataOutbound(array $record, ?string $modelName return $ret; } - - /** - * Filter related models from an inbound record using a permitted-path regex whitelist. - * - * @param array $record - * @param array $permittedRegexes Array of regex strings (including delimiters) - * @return array - * @since COmanage Registry v5.3.0 - */ - public static function filterRelatedInbound(array $record, array $permittedRegexes): array - { - $flat = Hash::flatten($record); - - foreach (array_keys($flat) as $k) { - $ok = false; - - foreach ($permittedRegexes as $p) { - if (preg_match($p, (string)$k)) { - $ok = true; - break; - } - } - - if (!$ok) { - unset($flat[$k]); - } - } - - return Hash::expand($flat); - } } diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php index 5733ff064..e02b34834 100644 --- a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -47,6 +47,25 @@ class PersonProfilesTable extends Table use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; + /** + * Associations that must never be included in Person Profile contain graphs. + * + * @var array + */ + protected const PERSON_PROFILE_CONTAIN_BLACKLIST = [ + 'ActorNotifications', + 'AuthenticatorStatuses', + 'PrimaryName', + 'HistoryRecords', + 'ExtIdentitySourceRecords', + 'JobHistoryRecords', + 'ResolverNotifications', + 'ApplicationStates', + 'Petitions', + 'ProvisioningHistoryRecords', + 'SubjectNotifications', + ]; + /** * Perform Cake Model initialization. * @@ -155,11 +174,6 @@ public function getPersonProfileContain(): array $contain = $this->getOwnedAssociationNames($People); - // Ensure PrimaryName is present (if configured as an association) - if (!in_array('PrimaryName', $contain, true) && $People->associations()->has('PrimaryName')) { - $contain[] = 'PrimaryName'; - } - // GroupMembers is not very useful without the Group record if (in_array('GroupMembers', $contain, true) && $People->associations()->has('GroupMembers')) { $contain = $this->replaceContainEntry($contain, 'GroupMembers', ['Groups']); @@ -173,11 +187,6 @@ public function getPersonProfileContain(): array $personRolesContain = $this->getOwnedAssociationNames($personRolesTarget); - // Also include the affiliation type lookup (belongsTo Types as affiliation_type) - if ($personRolesTarget->associations()->has('Types')) { - $personRolesContain[] = 'Types'; - } - $contain = $this->replaceContainEntry( $contain, 'PersonRoles', @@ -194,13 +203,15 @@ public function getPersonProfileContain(): array if ($externalIdentitiesTarget->associations()->has('ExternalIdentityRoles')) { $externalIdentityRolesTarget = $externalIdentitiesTarget->associations()->get('ExternalIdentityRoles')->getTarget(); - // Pull all owned children (MVEAs) for ExternalIdentityRoles (Addresses, AdHocAttributes, TelephoneNumbers, etc) + // Pull all owned children for ExternalIdentityRoles $externalIdentityRolesContain = $this->getOwnedAssociationNames($externalIdentityRolesTarget); - // Also include the affiliation type lookup (belongsTo Types as affiliation_type) - if ($externalIdentityRolesTarget->associations()->has('Types')) { - $externalIdentityRolesContain[] = 'Types'; - } + // Do NOT include the PersonRoles hasOne here. In ExternalIdentityRolesTable this is + // exposed via property "pipelined_person_role" and becomes "PipelinedPersonRole" in output. + $externalIdentityRolesContain = array_values(array_filter( + $externalIdentityRolesContain, + static fn(string $name): bool => $name !== 'PersonRoles' + )); $externalIdentitiesContain['ExternalIdentityRoles'] = $externalIdentityRolesContain; } @@ -228,7 +239,10 @@ protected function getOwnedAssociationNames(Table $table): array $associations = $table->associations()->getByType(['hasMany', 'hasOne']); foreach ($associations as $assoc) { - $names[] = $assoc->getName(); + $asocName = $assoc->getName(); + if (!in_array($asocName, self::PERSON_PROFILE_CONTAIN_BLACKLIST, true)) { + $names[] = $asocName; + } } sort($names); diff --git a/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php b/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php index 6df4309cb..255801483 100644 --- a/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php +++ b/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php @@ -30,7 +30,9 @@ namespace CoreApi\View\Helper; use Cake\ORM\TableRegistry; +use Cake\Utility\Inflector; use Cake\View\Helper; +use CoreApi\Lib\Utils\MessageFilter; class PersonProfileHelper extends Helper { @@ -43,6 +45,13 @@ class PersonProfileHelper extends Helper */ protected array $typeLabelCache = []; + /** + * Cache for schema columns keyed by model key (eg "EmailAddress") and by table alias. + * + * @var array> + */ + protected array $schemaColumnsCache = []; + /** * Resolve a type_id to a human label using the Types table. * @@ -77,448 +86,258 @@ public function typeLabel(?int $typeId): ?string } /** - * Format standard COmanage entity metadata. + * Format a full Person profile payload dynamically: + * - uses whatever schema columns/associations are present on the entity + * - moves metadata/system fields into "meta" (via MessageFilter) + * - keeps schema columns as first class keys + * - adds "type" label when a "type_id" field exists (keeps type_id too) * - * @param object $entity + * @param object $person * @return array * * @since COmanage v5.3.0 */ - public function formatMeta(object $entity): array + public function formatPersonProfile(object $person): array { - $meta = []; + $raw = $this->splitRootRecord($person, 'Person'); - if (isset($entity->id)) { - $meta['id'] = (int)$entity->id; - } - if (isset($entity->created)) { - $meta['created'] = $entity->created; - } - if (isset($entity->modified)) { - $meta['modified'] = $entity->modified; - } - if (isset($entity->deleted)) { - $meta['deleted'] = (bool)$entity->deleted; - } - if (isset($entity->revision)) { - $meta['revision'] = (int)$entity->revision; - } - if (isset($entity->actor_identifier)) { - $meta['actor_identifier'] = (string)$entity->actor_identifier; - } - - return $meta; - } - - /** - * Format an Address list. - * - * @param iterable $addresses - * @return array> - * - * @since COmanage v5.3.0 - */ - public function formatAddressList(iterable $addresses): array - { $out = []; - - foreach ($addresses as $a) { - $out[] = [ - 'meta' => $this->formatMeta($a), - 'country' => $a->country ?? null, - 'description' => $a->description ?? null, - 'language' => $a->language ?? null, - 'locality' => $a->locality ?? null, - 'postal_code' => $a->postal_code ?? null, - 'room' => $a->room ?? null, - 'state' => $a->state ?? null, - 'street' => $a->street ?? null, - 'type' => $this->typeLabel($a->type_id ?? null), - ]; + foreach ($raw as $modelName => $record) { + $filtered = MessageFilter::filterMetadataOutbound([$modelName => $record], $modelName); + $out[$modelName] = $filtered[$modelName] ?? []; } - return $out; + return $this->addDerivedFieldsRecursive($out); } /** - * Format an AdHocAttribute list. + * Split the root Person entity into the v2-ish response shape: + * [ + * 'Person' => [columns...], + * 'EmailAddress' => [[...], ...], + * 'Identifier' => [[...], ...], + * ... + * ] * - * @param iterable $attrs - * @return array> + * Associations are detected dynamically based on arrays returned by toArray(). * - * @since COmanage v5.3.0 - */ - public function formatAdHocAttributeList(iterable $attrs): array - { - $out = []; - - foreach ($attrs as $aha) { - $out[] = [ - 'meta' => $this->formatMeta($aha), - 'tag' => $aha->tag ?? null, - 'value' => $aha->value ?? null, - ]; - } - - return $out; - } - - /** - * Format a TelephoneNumber list. - * - * @param iterable $tels - * @return array> + * IMPORTANT: + * normalizeEntityToArray() already converts association property names + * (eg email_addresses) into model keys (eg EmailAddress). Do not inflect again here. * - * @since COmanage v5.3.0 + * @param object $entity + * @param string $rootModelName + * @return array */ - public function formatTelephoneNumberList(iterable $tels): array + protected function splitRootRecord(object $entity, string $rootModelName): array { - $out = []; + $arr = $this->normalizeEntityToArray($entity, $rootModelName); - foreach ($tels as $tn) { - $out[] = [ - 'meta' => $this->formatMeta($tn), - 'country_code' => $tn->country_code ?? null, - 'area_code' => $tn->area_code ?? null, - 'number' => $tn->number ?? null, - 'extension' => $tn->extension ?? null, - 'description' => $tn->description ?? null, - 'type' => $this->typeLabel($tn->type_id ?? null), - ]; - } - - return $out; - } + $root = []; + $associations = []; - /** - * Format an EmailAddress list. - * - * @param iterable $emails - * @return array> - * - * @since COmanage v5.3.0 - */ - public function formatEmailAddressList(iterable $emails): array - { - $out = []; + foreach ($arr as $k => $v) { + if (is_array($v)) { + $associations[$k] = $v; + continue; + } - foreach ($emails as $ea) { - $out[] = [ - 'meta' => $this->formatMeta($ea), - 'mail' => $ea->mail ?? null, - 'type' => $this->typeLabel($ea->type_id ?? null), - 'verified' => isset($ea->verified) ? (bool)$ea->verified : null, - ]; + $root[$k] = $v; } - return $out; - } - - /** - * Format an Identifier list. - * - * @param iterable $identifiers - * @return array> - * - * @since COmanage v5.3.0 - */ - public function formatIdentifierList(iterable $identifiers): array - { - $out = []; + $out = [ + $rootModelName => $root, + ]; - foreach ($identifiers as $id) { - $out[] = [ - 'meta' => $this->formatMeta($id), - 'identifier' => $id->identifier ?? null, - 'login' => isset($id->login) ? (bool)$id->login : null, - 'status' => $id->status ?? null, - 'type' => $this->typeLabel($id->type_id ?? null), - ]; + foreach ($associations as $modelKey => $value) { + $out[(string)$modelKey] = $value; } return $out; } /** - * Format a Name list. - * - * @param iterable $names - * @return array> + * Convert an entity into an array, recursively: + * - converts association property names to Model keys (eg email_addresses => EmailAddress) + * - normalizes enums to scalars + * - restricts scalar keys to schema columns for the resolved table * - * @since COmanage v5.3.0 + * @param object $entity + * @param string $modelName + * @return array */ - public function formatNameList(iterable $names): array + protected function normalizeEntityToArray(object $entity, string $modelName): array { - $out = []; + $arr = method_exists($entity, 'toArray') ? (array)$entity->toArray() : (array)$entity; - foreach ($names as $n) { - $out[] = [ - 'meta' => $this->formatMeta($n), - 'family' => $n->family ?? null, - 'formatted' => $n->display_name ?? null, - 'given' => $n->given ?? null, - 'language' => $n->language ?? null, - 'middle' => $n->middle ?? null, - 'prefix' => $n->honorific ?? null, - 'primary_name' => isset($n->primary_name) ? (bool)$n->primary_name : null, - 'suffix' => $n->suffix ?? null, - 'type' => $this->typeLabel($n->type_id ?? null), - ]; - } - - return $out; + return $this->normalizeRecordArray($arr, $modelName); } /** - * Format a Url list. + * Normalize a record array recursively, renaming association keys to model keys. + * Scalar keys are restricted to the model's schema columns. * - * @param iterable $urls - * @return array> - * - * @since COmanage v5.3.0 + * @param array $record + * @param string $modelName + * @return array */ - public function formatUrlList(iterable $urls): array + protected function normalizeRecordArray(array $record, string $modelName): array { $out = []; - foreach ($urls as $u) { - $out[] = [ - 'meta' => $this->formatMeta($u), - 'description' => $u->description ?? null, - 'url' => $u->url ?? null, - 'type' => $this->typeLabel($u->type_id ?? null), - ]; - } - - return $out; - } + $schemaColumns = $this->getSchemaColumnsForModelKey($modelName); + $schemaColumnSet = $schemaColumns !== null ? array_fill_keys($schemaColumns, true) : null; - /** - * Format a PersonRole list, including role-level MVEAs if present. - * - * @param iterable $personRoles - * @return array> - * - * @since COmanage v5.3.0 - */ - public function formatPersonRoleList(iterable $personRoles): array - { - $out = []; + foreach ($record as $k => $v) { + $key = (string)$k; - foreach ($personRoles as $pr) { - $role = [ - 'meta' => $this->formatMeta($pr), - 'cou_id' => $pr->cou_id ?? null, - 'title' => $pr->title ?? null, - 'o' => $pr->organization ?? null, - 'ou' => $pr->department ?? null, - 'valid_from' => $pr->valid_from ?? null, - 'valid_through' => $pr->valid_through ?? null, - 'status' => $pr->status ?? null, - 'sponsor_person_id' => $pr->sponsor_person_id ?? null, - 'affiliation' => $pr->affiliation_type->value ?? null, - 'ordr' => $pr->ordr ?? null, - ]; - - // Include role-level MVEAs whenever the association is present on the entity - if (isset($pr->addresses)) { - $role['Address'] = !empty($pr->addresses) ? $this->formatAddressList($pr->addresses) : []; + if ($v instanceof \BackedEnum) { + if ($schemaColumnSet === null || isset($schemaColumnSet[$key])) { + $out[$key] = $v->value; + } + continue; } - if (isset($pr->ad_hoc_attributes)) { - $role['AdHocAttribute'] = !empty($pr->ad_hoc_attributes) ? $this->formatAdHocAttributeList($pr->ad_hoc_attributes) : []; - } - if (isset($pr->telephone_numbers)) { - $role['TelephoneNumber'] = !empty($pr->telephone_numbers) ? $this->formatTelephoneNumberList($pr->telephone_numbers) : []; - } - - $out[] = $role; - } - - return $out; - } - - /** - * Format an ExternalIdentityRole list, including role-level MVEAs if present. - * - * @param iterable $externalIdentityRoles - * @return array> - * - * @since COmanage v5.3.0 - */ - public function formatExternalIdentityRoleList(iterable $externalIdentityRoles): array - { - $out = []; - foreach ($externalIdentityRoles as $eir) { - $role = [ - 'meta' => $this->formatMeta($eir), - 'title' => $eir->title ?? null, - 'o' => $eir->organization ?? null, - 'ou' => $eir->department ?? null, - 'valid_from' => $eir->valid_from ?? null, - 'valid_through' => $eir->valid_through ?? null, - 'status' => $eir->status ?? null, - 'affiliation' => $eir->affiliation_type->value ?? null, - 'ordr' => $eir->ordr ?? null, - ]; - - // Include EIR-level MVEAs whenever the association is present on the entity - if (isset($eir->addresses)) { - $role['Address'] = !empty($eir->addresses) ? $this->formatAddressList($eir->addresses) : []; - } - if (isset($eir->ad_hoc_attributes)) { - $role['AdHocAttribute'] = !empty($eir->ad_hoc_attributes) ? $this->formatAdHocAttributeList($eir->ad_hoc_attributes) : []; - } - if (isset($eir->telephone_numbers)) { - $role['TelephoneNumber'] = !empty($eir->telephone_numbers) ? $this->formatTelephoneNumberList($eir->telephone_numbers) : []; + if (is_array($v)) { + $assocModel = Inflector::classify($key); + + if (array_is_list($v)) { + $items = []; + foreach ($v as $idx => $item) { + if (is_object($item)) { + $items[$idx] = $this->normalizeEntityToArray($item, $assocModel); + } elseif (is_array($item)) { + $items[$idx] = $this->normalizeRecordArray($item, $assocModel); + } else { + $items[$idx] = $item; + } + } + $out[$assocModel] = $items; + continue; + } + + $out[$assocModel] = $this->normalizeRecordArray($v, $assocModel); + continue; } - $out[] = $role; + if ($schemaColumnSet === null || isset($schemaColumnSet[$key])) { + $out[$key] = $v; + } } return $out; } /** - * Format an ExternalIdentity list, including nested associations when present. + * Get schema columns for a given API model key (eg "PersonRole" or "EmailAddress"). * - * @param iterable $externalIdentities - * @return array> + * Returns null if the table cannot be resolved (fallback: do not enforce schema). * - * @since COmanage v5.3.0 + * @param string $modelKey + * @return array|null */ - public function formatExternalIdentityList(iterable $externalIdentities): array + protected function getSchemaColumnsForModelKey(string $modelKey): ?array { - $out = []; - - foreach ($externalIdentities as $ei) { - $ext = [ - 'meta' => $this->formatMeta($ei), - 'co_id' => $ei->co_id ?? null, - 'title' => $ei->title ?? null, - 'o' => $ei->organization ?? null, - 'ou' => $ei->department ?? null, - 'valid_from' => $ei->valid_from ?? null, - 'valid_through' => $ei->valid_through ?? null, - 'status' => $ei->status ?? null, - 'affiliation' => null, - 'date_of_birth' => $ei->date_of_birth ?? null, - ]; - - // Cake hasMany ExternalIdentityRoles => entity field external_identity_roles - if (isset($ei->external_identity_roles)) { - $ext['ExternalIdentityRole'] = !empty($ei->external_identity_roles) - ? $this->formatExternalIdentityRoleList($ei->external_identity_roles) - : []; - } - - if (!empty($ei->addresses)) { - $ext['Address'] = $this->formatAddressList($ei->addresses); - } - - if (!empty($ei->ad_hoc_attributes)) { - $ext['AdHocAttribute'] = $this->formatAdHocAttributeList($ei->ad_hoc_attributes); - } - - if (!empty($ei->email_addresses)) { - $ext['EmailAddress'] = $this->formatEmailAddressList($ei->email_addresses); - } - - if (!empty($ei->identifiers)) { - $ext['Identifier'] = $this->formatIdentifierList($ei->identifiers); - } - - if (!empty($ei->names)) { - $ext['Name'] = $this->formatNameList($ei->names); - } + if (isset($this->schemaColumnsCache[$modelKey])) { + return $this->schemaColumnsCache[$modelKey]; + } - if (!empty($ei->telephone_numbers)) { - $ext['TelephoneNumber'] = $this->formatTelephoneNumberList($ei->telephone_numbers); - } + $tableAlias = $this->modelKeyToTableAlias($modelKey); + if ($tableAlias === null) { + return null; + } - if (!empty($ei->urls)) { - $ext['Url'] = $this->formatUrlList($ei->urls); - } + $cacheKey = 'table:' . $tableAlias; + if (isset($this->schemaColumnsCache[$cacheKey])) { + $this->schemaColumnsCache[$modelKey] = $this->schemaColumnsCache[$cacheKey]; + return $this->schemaColumnsCache[$modelKey]; + } - $out[] = $ext; + try { + $table = TableRegistry::getTableLocator()->get($tableAlias); + $columns = $table->getSchema()->columns(); + } catch (\Throwable $e) { + return null; } - return $out; + $this->schemaColumnsCache[$cacheKey] = $columns; + $this->schemaColumnsCache[$modelKey] = $columns; + + return $columns; } /** - * Format a GroupMember list. - * - * @param iterable $groupMembers - * @return array> + * Map API model keys to Cake table aliases. * - * @since COmanage v5.3.0 + * @param string $modelKey + * @return string|null */ - public function formatGroupMemberList(iterable $groupMembers): array + protected function modelKeyToTableAlias(string $modelKey): ?string { - $out = []; - - foreach ($groupMembers as $gm) { - $out[] = [ - 'meta' => $this->formatMeta($gm), - 'group_id' => (int)($gm->group_id ?? 0), - 'member' => isset($gm->member) ? (bool)$gm->member : null, - 'owner' => isset($gm->owner) ? (bool)$gm->owner : null, - 'group_nesting_id' => $gm->group_nesting_id ?? null, - ]; + if ($modelKey === 'Person') { + return 'People'; } - return $out; + return Inflector::pluralize($modelKey); } /** - * Format a full Person profile payload. + * Add derived fields recursively. + * Current behavior: + * - if "type_id" is present and no "type" exists, add "type" label (keeps type_id too) * - * @param object $person - * @return array - * - * @since COmanage v5.3.0 + * @param mixed $node + * @return mixed */ - public function formatPersonProfile(object $person): array + protected function addDerivedFieldsRecursive(mixed $node): mixed { - $out = [ - 'Person' => [ - 'meta' => $this->formatMeta($person), - 'co_id' => (int)($person->co_id ?? 0), - 'date_of_birth' => $person->date_of_birth ?? null, - 'status' => (string)($person->status ?? ''), - 'timezone' => $person->timezone ?? null, - ], - ]; - - if (!empty($person->group_members)) { - $out['GroupMember'] = $this->formatGroupMemberList($person->group_members); + if (!is_array($node)) { + return $node; } - if (!empty($person->email_addresses)) { - $out['EmailAddress'] = $this->formatEmailAddressList($person->email_addresses); + if (!$this->isAssoc($node)) { + foreach ($node as $i => $item) { + $node[$i] = $this->addDerivedFieldsRecursive($item); + } + return $node; } - if (!empty($person->identifiers)) { - $out['Identifier'] = $this->formatIdentifierList($person->identifiers); + foreach ($node as $k => $v) { + $node[$k] = $this->addDerivedFieldsRecursive($v); } - if (!empty($person->names)) { - $out['Name'] = $this->formatNameList($person->names); - } + if (array_key_exists('type_id', $node) && !array_key_exists('type', $node)) { + $typeId = $node['type_id']; + $typeId = is_int($typeId) ? $typeId : (is_numeric($typeId) ? (int)$typeId : null); - if (!empty($person->urls)) { - $out['Url'] = $this->formatUrlList($person->urls); + $node['type'] = $this->typeLabel($typeId); } - if (!empty($person->person_roles)) { - $out['PersonRole'] = $this->formatPersonRoleList($person->person_roles); + // PersonRole / ExternalIdentityRole: expose affiliation as the Types.value for affiliation_type_id + if (array_key_exists('affiliation_type_id', $node) && !array_key_exists('affiliation', $node)) { + $affTypeId = $node['affiliation_type_id']; + $affTypeId = is_int($affTypeId) ? $affTypeId : (is_numeric($affTypeId) ? (int)$affTypeId : null); + + $node['affiliation'] = $this->typeLabel($affTypeId); + + // Remove the *_type_id field per desired API shape + unset($node['affiliation_type_id']); } - if (!empty($person->external_identities)) { - $out['ExternalIdentity'] = $this->formatExternalIdentityList($person->external_identities); + // If any affiliation object slipped in (eg due to contain), remove it + if (array_key_exists('AffiliationType', $node) && is_array($node['AffiliationType'])) { + unset($node['AffiliationType']); } - return $out; + return $node; + } + + /** + * @param array $arr + * @return bool + */ + protected function isAssoc(array $arr): bool + { + return !array_is_list($arr); } } diff --git a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php index b587828a3..5c6314997 100644 --- a/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php @@ -47,6 +47,7 @@ $items = []; foreach ($vv_people as $p) { if ($p !== null) { + // Dynamic schema-driven formatting happens inside the helper now $items[] = $this->PersonProfile->formatPersonProfile($p); } } From ab000e5de585dfc0e695f868e31a9f26a24e627f Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 8 Jul 2026 10:31:11 +0000 Subject: [PATCH 12/16] Create new Person --- .../PersonProfileApiV2Controller.php | 93 ++-- .../Traits/ExternalIdentityCreateTrait.php | 460 ++++++++++++++++++ .../Lib/Traits/PersonProfileUpsertTrait.php | 81 +-- .../CoreApi/src/Lib/Utils/Utilities.php | 130 +++++ 4 files changed, 628 insertions(+), 136 deletions(-) create mode 100644 app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php create mode 100644 app/plugins/CoreApi/src/Lib/Utils/Utilities.php diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index 14afe71e5..91bb5ebd6 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -32,16 +32,18 @@ use App\Controller\StandardApiController; use Cake\Event\EventInterface; use Cake\Http\Exception\BadRequestException; -use Cake\ORM\Table; use Cake\ORM\TableRegistry; use CoreApi\Lib\Enum\ResponseTypesEnum; use CoreApi\Lib\Traits\ApiPaginationTrait; use CoreApi\Lib\Traits\PersonProfileUpsertTrait; +use CoreApi\Lib\Traits\ExternalIdentityCreateTrait; +use CoreApi\Lib\Utils\Utilities; class PersonProfileApiV2Controller extends StandardApiController { use ApiPaginationTrait; use PersonProfileUpsertTrait; + use ExternalIdentityCreateTrait; /** * Loaded PersonProfiles configuration for the current API (person_profiles row). @@ -89,7 +91,7 @@ public function beforeFilter(EventInterface $event): void throw new BadRequestException(__d('error', 'invalid.request')); } - $this->person_profile_cfg = $this->getPersonProfilesTable() + $this->person_profile_cfg = Utilities::getPersonProfilesTable() ->find() ->where(['api_id' => (int)$api->id]) ->contain(['IdentifierTypes']) @@ -167,8 +169,8 @@ public function calculateRequestedCOID(): ?int */ public function index(string $coid): void { - $People = $this->getPeopleTable(); - $Identifiers = $this->getIdentifiersTable(); + $People = Utilities::getPeopleTable(); + $Identifiers = Utilities::getIdentifiersTable(); try { $coId = (int)$coid; @@ -182,7 +184,7 @@ public function index(string $coid): void } $personId = $Identifiers->lookupPerson($typeId, $identifier); - $person = $this->getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); + $person = Utilities::getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); $this->set('vv_people', [$person]); $this->set('vv_page', 1); @@ -209,7 +211,7 @@ public function index(string $coid): void } // Build query + contain graph - $contain = $this->getPersonProfilesTable()->getPersonProfileContain(); + $contain = Utilities::getPersonProfilesTable()->getPersonProfileContain(); $baseQuery = $People->find() ->where($conditions) @@ -288,7 +290,7 @@ public function create(string $coid): void */ public function read(string $coid, string $identifier): void { - $Identifiers = $this->getIdentifiersTable(); + $Identifiers = Utilities::getIdentifiersTable(); try { $coId = (int)$coid; @@ -299,7 +301,7 @@ public function read(string $coid, string $identifier): void } $personId = $Identifiers->lookupPerson($typeId, $identifier); - $person = $this->getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); + $person = Utilities::getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); $this->set('vv_person', $person); $this->response = $this->response->withStatus(200)->withType('application/json'); @@ -377,8 +379,8 @@ public function put(string $coid, string $identifier): void */ public function upsert(string $coid, ?string $identifier = null): void { - $People = $this->getPeopleTable(); - $Identifiers = $this->getIdentifiersTable(); + $People = Utilities::getPeopleTable(); + $Identifiers = Utilities::getIdentifiersTable(); $payload = $this->request->getData(); if (empty($payload) || !is_array($payload)) { @@ -389,8 +391,12 @@ public function upsert(string $coid, ?string $identifier = null): void throw new BadRequestException(__d('error', 'invalid.request')); } - // Ignore ExternalIdentity entirely (read-only), whether present or not. - unset($payload['ExternalIdentity']); + // ExternalIdentity: + // - allow on create (POST) + // - ignore on update (PUT) for now + if ($identifier !== null) { + unset($payload['ExternalIdentity']); + } $results = []; $resultCode = 400; @@ -402,11 +408,23 @@ public function upsert(string $coid, ?string $identifier = null): void if ($identifier === null) { $personData = $this->normalizePersonInbound($payload['Person'], $coId); + // 1) Create person + person associations (including affiliations / PersonRoles) $person = $People->newEntity($personData); $People->saveOrFail($person); $this->applyPersonProfileAssociations($person->id, $coId, $payload); + // 2) Then create external identities + their associations (create-only) + if (array_key_exists('ExternalIdentity', $payload)) { + $incomingEis = is_array($payload['ExternalIdentity']) ? $payload['ExternalIdentity'] : []; + foreach ($incomingEis as $eiRaw) { + if (!is_array($eiRaw)) { + continue; + } + $this->createExternalIdentityWithAssociations((int)$person->id, $coId, $eiRaw); + } + } + return [ 'status' => 201, 'body' => ['id' => (int)$person->id], @@ -479,8 +497,8 @@ public function upsert(string $coid, ?string $identifier = null): void */ public function delete(string $coid, string $identifier): void { - $People = $this->getPeopleTable(); - $Identifiers = $this->getIdentifiersTable(); + $People = Utilities::getPeopleTable(); + $Identifiers = Utilities::getIdentifiersTable(); $results = []; $resultCode = 500; @@ -513,54 +531,7 @@ public function delete(string $coid, string $identifier): void $this->set('vv_results', $results); } - /** - * Obtain the PersonProfiles (configuration) table. - * - * @return \CoreApi\Model\Table\PersonProfilesTable - */ - protected function getPersonProfilesTable(): \CoreApi\Model\Table\PersonProfilesTable - { - /** @var \CoreApi\Model\Table\PersonProfilesTable $PersonProfiles */ - $PersonProfiles = TableRegistry::getTableLocator()->get('CoreApi.PersonProfiles'); - - return $PersonProfiles; - } - - /** - * Obtain the People table. - * - * @since COmanage Registry v5.3.0 - * @return Table People table instance - */ - protected function getPeopleTable(): Table - { - return TableRegistry::getTableLocator()->get('People'); - } - /** - * Obtain the Identifiers table. - * - * @since COmanage Registry v5.3.0 - * @return Table People table instance - */ - protected function getIdentifiersTable(): Table - { - return TableRegistry::getTableLocator()->get('Identifiers'); - } - - /** - * Obtain the Types table. - * - * @since COmanage Registry v5.3.0 - * @return \App\Model\Table\TypesTable - */ - protected function getTypesTable(): \App\Model\Table\TypesTable - { - /** @var \App\Model\Table\TypesTable $Types */ - $Types = TableRegistry::getTableLocator()->get('Types'); - - return $Types; - } /** * Return the configured identifier type id (or null if not configured). diff --git a/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php new file mode 100644 index 000000000..20e7b35b4 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php @@ -0,0 +1,460 @@ + $payload + * @return void + * @since COmanage Registry v5.3.0 + */ + protected function createExternalIdentitiesFromPayload(int $personId, int $coId, array $payload): void + { + if (!array_key_exists('ExternalIdentity', $payload)) { + return; + } + + $incomingEis = is_array($payload['ExternalIdentity']) ? $payload['ExternalIdentity'] : []; + + foreach ($incomingEis as $eiRaw) { + if (!is_array($eiRaw)) { + continue; + } + + $this->createExternalIdentityWithAssociations($personId, $coId, $eiRaw); + } + } + + /** + * Create one ExternalIdentity and then save its associations incrementally. + * + * This is intentionally "create-only" and not reconcile/replace semantics. + * + * @param int $personId + * @param int $coId + * @param array $externalIdentityRaw + * @return int ExternalIdentity.id + * @since COmanage Registry v5.3.0 + */ + protected function createExternalIdentityWithAssociations(int $personId, int $coId, array $externalIdentityRaw): int + { + /** @var \App\Model\Table\PeopleTable $People */ + $People = Utilities::getPeopleTable(); + + $ExternalIdentities = $People->associations()->get('ExternalIdentities')->getTarget(); + + // 1) Create ExternalIdentity row (only scalar/root fields) + $ei = MessageFilter::filterMetadataInbound($externalIdentityRaw, 'ExternalIdentity', [ + 'person_id', + 'external_identity_source_id', + ]); + + $ei = Utilities::normalizeInboundDateTimes($ei); + $ei['person_id'] = $personId; + + // Required field: status (your error shows it cannot be empty) + if (!array_key_exists('status', $ei) || $ei['status'] === null || $ei['status'] === '') { + $ei['status'] = 'A'; + } + + try { + $entity = $ExternalIdentities->newEntity($ei); + $ExternalIdentities->saveOrFail($entity); + } catch (PersistenceFailedException $e) { + $errors = $e->getEntity()->getErrors(); + $detail = json_encode($errors); + throw new BadRequestException("Invalid ExternalIdentity during create: " . ($detail ?: $e->getMessage())); + } + + $externalIdentityId = (int)$entity->id; + + // 2) Add top-level EI associations one by one (create-only) + $this->applyExternalIdentityAssociations( + externalIdentityId: $externalIdentityId, + coId: $coId, + externalIdentityRaw: $externalIdentityRaw + ); + + return $externalIdentityId; + } + + /** + * Create-only association saves for an ExternalIdentity. + * + * @param int $externalIdentityId + * @param int $coId + * @param array $externalIdentityRaw + * @return void + * @since COmanage Registry v5.3.0 + */ + protected function applyExternalIdentityAssociations(int $externalIdentityId, int $coId, array $externalIdentityRaw): void + { + /** @var \App\Model\Table\PeopleTable $People */ + $People = Utilities::getPeopleTable(); + + /** @var \App\Model\Table\TypesTable $Types */ + $Types = Utilities::getTypesTable(); + + $ExternalIdentities = $People->associations()->get('ExternalIdentities')->getTarget(); + + // Load existing sets for reconcile*() (empty is fine too) + $loadedEi = $ExternalIdentities->find() + ->where(['ExternalIdentities.id' => $externalIdentityId]) + ->contain([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles', + ]) + ->firstOrFail(); + + // Address + if (array_key_exists('Address', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['Address']) ? $externalIdentityRaw['Address'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('Addresses')->getTarget(), + existing: $loadedEi->addresses ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Addresses.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // AdHocAttribute + if (array_key_exists('AdHocAttribute', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['AdHocAttribute']) ? $externalIdentityRaw['AdHocAttribute'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('AdHocAttributes')->getTarget(), + existing: $loadedEi->ad_hoc_attributes ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: null, + fieldMap: [] + ); + } + + // EmailAddress + if (array_key_exists('EmailAddress', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['EmailAddress']) ? $externalIdentityRaw['EmailAddress'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('EmailAddresses')->getTarget(), + existing: $loadedEi->email_addresses ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'EmailAddresses.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // Identifier + if (array_key_exists('Identifier', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['Identifier']) ? $externalIdentityRaw['Identifier'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('Identifiers')->getTarget(), + existing: $loadedEi->identifiers ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Identifiers.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // Name + if (array_key_exists('Name', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['Name']) ? $externalIdentityRaw['Name'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('Names')->getTarget(), + existing: $loadedEi->names ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Names.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // Pronoun + if (array_key_exists('Pronoun', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['Pronoun']) ? $externalIdentityRaw['Pronoun'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('Pronouns')->getTarget(), + existing: $loadedEi->pronouns ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: null, + fieldMap: [] + ); + } + + // TelephoneNumber + if (array_key_exists('TelephoneNumber', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['TelephoneNumber']) ? $externalIdentityRaw['TelephoneNumber'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('TelephoneNumbers')->getTarget(), + existing: $loadedEi->telephone_numbers ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'TelephoneNumbers.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // Url + if (array_key_exists('Url', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['Url']) ? $externalIdentityRaw['Url'] : []; + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('Urls')->getTarget(), + existing: $loadedEi->urls ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Urls.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // ExternalIdentityRole (create-only, no nested role MVEAs yet) + if (array_key_exists('ExternalIdentityRole', $externalIdentityRaw)) { + $incoming = is_array($externalIdentityRaw['ExternalIdentityRole']) ? $externalIdentityRaw['ExternalIdentityRole'] : []; + + $this->reconcileExternalIdentityHasMany( + table: $ExternalIdentities->associations()->get('ExternalIdentityRoles')->getTarget(), + existing: $loadedEi->external_identity_roles ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'affiliation', + // IMPORTANT: types are configured under PersonRoles.affiliation_type, not ExternalIdentityRoles.affiliation_type + 'attribute' => 'PersonRoles.affiliation_type', + 'targetField' => 'affiliation_type_id', + 'typesTable' => $Types, + ], + fieldMap: [ + 'o' => 'organization', + 'ou' => 'department', + ] + ); + } + } + + /** + * Reconcile a hasMany set using "id-present => update, id-absent => insert, missing => delete". + * + * Named uniquely to avoid trait collision with PersonProfileUpsertTrait::reconcileHasMany(). + * + * @param \Cake\ORM\Table $table + * @param iterable $existing + * @param array $incoming + * @param string $parentFk + * @param int $parentId + * @param int $coId + * @param array|null $typeSpec + * @param array $fieldMap + * @param bool $returnsNewIdMap + * @return array Map of incoming index => newly created id (only when $returnsNewIdMap=true) + * @since COmanage Registry v5.3.0 + */ + protected function reconcileExternalIdentityHasMany( + \Cake\ORM\Table $table, + iterable $existing, + array $incoming, + string $parentFk, + int $parentId, + int $coId, + ?array $typeSpec, + array $fieldMap, + bool $returnsNewIdMap = false + ): array { + $existingById = []; + foreach ($existing as $e) { + if (!empty($e->id)) { + $existingById[(int)$e->id] = $e; + } + } + + $seenIds = []; + $newIdMap = []; + + foreach ($incoming as $idx => $raw) { + if (!is_array($raw)) { + continue; + } + + $data = Utilities::extractInboundId($raw); + $data = MessageFilter::filterMetadataInbound($data, $table->getAlias()); + + // Apply field mapping (API field => DB field) + foreach ($fieldMap as $apiField => $dbField) { + if (array_key_exists($apiField, $raw)) { + $data[$dbField] = $raw[$apiField]; + } + } + + // Default required status for ExternalIdentityRoles if missing/empty + if ($table->getAlias() === 'ExternalIdentityRoles') { + if (!array_key_exists('status', $data) || $data['status'] === null || $data['status'] === '') { + $data['status'] = 'A'; + } + } + + // Type mapping (API "type"/etc label => type_id) + if ($typeSpec !== null) { + $apiTypeField = (string)$typeSpec['field']; + $attribute = (string)$typeSpec['attribute']; + $targetField = (string)$typeSpec['targetField']; + + if (array_key_exists($apiTypeField, $raw) && is_string($raw[$apiTypeField]) && $raw[$apiTypeField] !== '') { + /** @var \App\Model\Table\TypesTable $Types */ + $Types = $typeSpec['typesTable']; + + try { + $data[$targetField] = $Types->getTypeId($coId, $attribute, $raw[$apiTypeField]); + } catch (RecordNotFoundException $e) { + throw new BadRequestException( + "Unknown type label for {$table->getAlias()} at index {$idx}: " + . "attribute='{$attribute}', value='{$raw[$apiTypeField]}', coId={$coId}" + ); + } + } + } + + $data = Utilities::normalizeInboundDateTimes($data); + $data[$parentFk] = $parentId; + + $id = $data['id'] ?? null; + + try { + if (!empty($id)) { + $id = (int)$id; + + if (!isset($existingById[$id])) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $entity = $existingById[$id]; + unset($data['id']); // avoid primary key reassignment + $entity = $table->patchEntity($entity, $data); + $table->saveOrFail($entity); + + $seenIds[$id] = true; + } else { + $entity = $table->newEntity($data); + $table->saveOrFail($entity); + + if ($returnsNewIdMap) { + $newIdMap[(int)$idx] = (int)$entity->id; + } + } + } catch (PersistenceFailedException $e) { + $errors = $e->getEntity()->getErrors(); + $detail = json_encode($errors); + + $model = $table->getAlias(); + $op = !empty($id) ? 'update' : 'create'; + + throw new BadRequestException("Invalid {$model} at index {$idx} during {$op}: " . ($detail ?: $e->getMessage())); + } + } + + // Delete any existing records not present in incoming set + foreach ($existingById as $eid => $entity) { + if (!isset($seenIds[(int)$eid])) { + $table->deleteOrFail($entity); + } + } + + return $newIdMap; + } +} \ No newline at end of file diff --git a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php index 8e285cf05..87b1e5c38 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -31,9 +31,9 @@ use Cake\Http\Exception\BadRequestException; use Cake\ORM\Exception\PersistenceFailedException; -use Cake\ORM\Table; use Cake\ORM\TableRegistry; use CoreApi\Lib\Utils\MessageFilter; +use CoreApi\Lib\Utils\Utilities; /** * Trait PersonProfileUpsertTrait @@ -162,10 +162,10 @@ protected function extractPersonIdFromPayload(array $personRaw): ?int protected function applyPersonProfileAssociations(int $personId, int $coId, array $payload, ?object $loadedPerson = null): void { /** @var \App\Model\Table\PeopleTable $People */ - $People = $this->getPeopleTable(); + $People = Utilities::getPeopleTable(); /** @var \App\Model\Table\TypesTable $Types */ - $Types = $this->getTypesTable(); + $Types = Utilities::getTypesTable(); if ($loadedPerson === null) { $loadedPerson = $People->find() @@ -322,7 +322,7 @@ protected function applyPersonProfileAssociations(int $personId, int $coId, arra continue; } - $incomingRole = $this->extractInboundId($incomingRoleRaw, 'PersonRole'); + $incomingRole = Utilities::extractInboundId($incomingRoleRaw, 'PersonRole'); $roleId = $incomingRole['id'] ?? null; if (empty($roleId) && isset($roleIdMap[$idx])) { @@ -434,7 +434,7 @@ protected function reconcileHasMany( continue; } - $data = $this->extractInboundId($raw, $table->getAlias()); + $data = Utilities::extractInboundId($raw, $table->getAlias()); $data = MessageFilter::filterMetadataInbound($data, $table->getAlias()); // Apply field mapping (API field => DB field) @@ -458,7 +458,7 @@ protected function reconcileHasMany( } } - $data = $this->normalizeInboundDateTimes($data); + $data = Utilities::normalizeInboundDateTimes($data); $data[$parentFk] = $parentId; @@ -564,73 +564,4 @@ protected function reconcileHasMany( return $newIdMap; } - - /** - * Normalize inbound date and time fields ('valid_from', 'valid_through') to a standard format. - * Converts date-time strings to 'Y-m-d H:i:s' format or null if invalid or empty. - * - * @param array $data Input array containing potential date-time fields. - * @return array The normalized array with standardized date-time fields. - * @since COmanage Registry v5.3.0 - */ - protected function normalizeInboundDateTimes(array $data): array - { - foreach (['valid_from', 'valid_through'] as $k) { - if (!array_key_exists($k, $data)) { - continue; - } - - $v = $data[$k]; - - if ($v === null || $v === '') { - $data[$k] = null; - continue; - } - - if (!is_string($v)) { - continue; - } - - try { - $dt = new \DateTimeImmutable($v); - $data[$k] = $dt->format('Y-m-d H:i:s'); - } catch (\Exception $e) { - } - } - - return $data; - } - - /** - * Extracts meta.id into top-level id if present (without keeping other meta fields). - * - * @param array $raw - * @param string $modelName - * @return array - * @since COmanage Registry v5.3.0 - */ - protected function extractInboundId(array $raw, string $modelName): array - { - if (!empty($raw['meta']) && is_array($raw['meta']) && array_key_exists('id', $raw['meta'])) { - $raw['id'] = $raw['meta']['id']; - } - - return $raw; - } - - /** - * Obtain the People table. - * - * @return Table - * @since COmanage Registry v5.3.0 - */ - abstract protected function getPeopleTable(): Table; - - /** - * Obtain the Types table. - * - * @return \App\Model\Table\TypesTable - * @since COmanage Registry v5.3.0 - */ - abstract protected function getTypesTable(): \App\Model\Table\TypesTable; } diff --git a/app/plugins/CoreApi/src/Lib/Utils/Utilities.php b/app/plugins/CoreApi/src/Lib/Utils/Utilities.php new file mode 100644 index 000000000..c007a6a7d --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Utils/Utilities.php @@ -0,0 +1,130 @@ + $data Input array containing potential date-time fields. + * @return array The normalized array with standardized date-time fields. + * @since COmanage Registry v5.3.0 + */ + public static function normalizeInboundDateTimes(array $data): array + { + foreach (['valid_from', 'valid_through'] as $k) { + if (!array_key_exists($k, $data)) { + continue; + } + + $v = $data[$k]; + + if ($v === null || $v === '') { + $data[$k] = null; + continue; + } + + if (!is_string($v)) { + continue; + } + + try { + $dt = new \DateTimeImmutable($v); + $data[$k] = $dt->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + } + } + + return $data; + } + + /** + * Extract meta.id into top-level id if present (without keeping other meta fields). + * + * @param array $raw + * @return array + * @since COmanage Registry v5.3.0 + */ + public static function extractInboundId(array $raw): array + { + if (!empty($raw['meta']) && is_array($raw['meta']) && array_key_exists('id', $raw['meta'])) { + $raw['id'] = $raw['meta']['id']; + } + + return $raw; + } + + /** + * Obtain the People table. + * + * @since COmanage Registry v5.3.0 + * @return Table People table instance + */ + public static function getPeopleTable(): Table + { + return TableRegistry::getTableLocator()->get('People'); + } + + /** + * Obtain the Identifiers table. + * + * @since COmanage Registry v5.3.0 + * @return Table People table instance + */ + public static function getIdentifiersTable(): Table + { + return TableRegistry::getTableLocator()->get('Identifiers'); + } + + /** + * Obtain the Types table. + * + * @since COmanage Registry v5.3.0 + * @return \App\Model\Table\TypesTable + */ + public static function getTypesTable(): \App\Model\Table\TypesTable + { + return TableRegistry::getTableLocator()->get('Types'); + } + + /** + * Obtain the PersonProfiles (configuration) table. + * + * @return \CoreApi\Model\Table\PersonProfilesTable + */ + public static function getPersonProfilesTable(): \CoreApi\Model\Table\PersonProfilesTable + { + return TableRegistry::getTableLocator()->get('CoreApi.PersonProfiles');; + } +} \ No newline at end of file From 624744da43a85dc48fac2992ab738bcf84143b34 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 8 Jul 2026 10:47:18 +0000 Subject: [PATCH 13/16] Upsert fix dynamic contains. Fix mveas reconciliation. --- .../PersonProfileApiV2Controller.php | 15 +--- .../Lib/Traits/PersonProfileUpsertTrait.php | 70 +++++++++++++++---- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index 91bb5ebd6..385aeb298 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -32,7 +32,6 @@ use App\Controller\StandardApiController; use Cake\Event\EventInterface; use Cake\Http\Exception\BadRequestException; -use Cake\ORM\TableRegistry; use CoreApi\Lib\Enum\ResponseTypesEnum; use CoreApi\Lib\Traits\ApiPaginationTrait; use CoreApi\Lib\Traits\PersonProfileUpsertTrait; @@ -444,20 +443,10 @@ public function upsert(string $coid, ?string $identifier = null): void throw new BadRequestException(__d('error', 'invalid.request')); } + $contain = Utilities::getPersonProfilesTable()->getPersonProfileContain(); $person = $People->find() ->where(['People.id' => $personId, 'People.co_id' => $coId]) - ->contain([ - 'EmailAddresses', - 'Identifiers', - 'Names', - 'Urls', - 'GroupMembers', - 'PersonRoles' => [ - 'Addresses', - 'AdHocAttributes', - 'TelephoneNumbers', - ], - ]) + ->contain($contain) ->firstOrFail(); $personData = $this->normalizePersonInbound($payload['Person'], $coId); diff --git a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php index 87b1e5c38..f54d76e2b 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -168,23 +168,69 @@ protected function applyPersonProfileAssociations(int $personId, int $coId, arra $Types = Utilities::getTypesTable(); if ($loadedPerson === null) { + $contain = Utilities::getPersonProfilesTable()->getPersonProfileContain(); + $loadedPerson = $People->find() ->where(['People.id' => $personId, 'People.co_id' => $coId]) - ->contain([ - 'EmailAddresses', - 'Identifiers', - 'Names', - 'Urls', - 'GroupMembers', - 'PersonRoles' => [ - 'Addresses', - 'AdHocAttributes', - 'TelephoneNumbers', - ], - ]) + ->contain($contain) ->firstOrFail(); } + // Address (Person-level) + if (array_key_exists('Address', $payload)) { + $incoming = is_array($payload['Address']) ? $payload['Address'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('Addresses')->getTarget(), + existing: $loadedPerson->addresses ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'Addresses.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + + // AdHocAttribute (Person-level) + if (array_key_exists('AdHocAttribute', $payload)) { + $incoming = is_array($payload['AdHocAttribute']) ? $payload['AdHocAttribute'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('AdHocAttributes')->getTarget(), + existing: $loadedPerson->ad_hoc_attributes ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: null, + fieldMap: [] + ); + } + + // TelephoneNumber (Person-level) + if (array_key_exists('TelephoneNumber', $payload)) { + $incoming = is_array($payload['TelephoneNumber']) ? $payload['TelephoneNumber'] : []; + $this->reconcileHasMany( + table: $People->associations()->get('TelephoneNumbers')->getTarget(), + existing: $loadedPerson->telephone_numbers ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => 'TelephoneNumbers.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } + // EmailAddress if (array_key_exists('EmailAddress', $payload)) { $incoming = is_array($payload['EmailAddress']) ? $payload['EmailAddress'] : []; From d9cde411a1e0abf224fe5fa5e33fd9d648c0b005 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jul 2026 09:03:17 +0000 Subject: [PATCH 14/16] Refactor applyPersonProfileAssociations to a more dynamic version --- .../PersonProfileApiV2Controller.php | 9 +- .../Lib/Traits/PersonProfileUpsertTrait.php | 415 ++++++------------ .../CoreApi/src/Lib/Utils/Utilities.php | 24 + 3 files changed, 159 insertions(+), 289 deletions(-) diff --git a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php index 385aeb298..a5107505c 100644 --- a/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -36,6 +36,7 @@ use CoreApi\Lib\Traits\ApiPaginationTrait; use CoreApi\Lib\Traits\PersonProfileUpsertTrait; use CoreApi\Lib\Traits\ExternalIdentityCreateTrait; +use CoreApi\Lib\Utils\MessageFilter; use CoreApi\Lib\Utils\Utilities; class PersonProfileApiV2Controller extends StandardApiController @@ -405,7 +406,8 @@ public function upsert(string $coid, ?string $identifier = null): void $result = $People->getConnection()->transactional(function () use ($payload, $identifier, $coId, $People, $Identifiers): array { if ($identifier === null) { - $personData = $this->normalizePersonInbound($payload['Person'], $coId); + $personData = MessageFilter::filterMetadataInbound($payload['Person'], 'Person'); + $personData['co_id'] = $coId; // 1) Create person + person associations (including affiliations / PersonRoles) $person = $People->newEntity($personData); @@ -438,7 +440,7 @@ public function upsert(string $coid, ?string $identifier = null): void $personId = $Identifiers->lookupPerson($typeId, $identifier); // If the payload includes Person.meta.id, it must match the resolved Person.id. - $payloadPersonId = $this->extractPersonIdFromPayload($payload['Person']); + $payloadPersonId = Utilities::extractPersonIdFromPayload($payload['Person']); if ($payloadPersonId !== null && $payloadPersonId !== $personId) { throw new BadRequestException(__d('error', 'invalid.request')); } @@ -449,7 +451,8 @@ public function upsert(string $coid, ?string $identifier = null): void ->contain($contain) ->firstOrFail(); - $personData = $this->normalizePersonInbound($payload['Person'], $coId); + $personData = MessageFilter::filterMetadataInbound($payload['Person'], 'Person'); + $personData['co_id'] = $coId; $person = $People->patchEntity($person, $personData); $People->saveOrFail($person); diff --git a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php index f54d76e2b..49865756e 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -32,6 +32,7 @@ use Cake\Http\Exception\BadRequestException; use Cake\ORM\Exception\PersistenceFailedException; use Cake\ORM\TableRegistry; +use Cake\Utility\Inflector; use CoreApi\Lib\Utils\MessageFilter; use CoreApi\Lib\Utils\Utilities; @@ -57,7 +58,7 @@ * * ## ExternalIdentity handling * - 'ExternalIdentity' is read-only for this API. - * - If the payload includes 'ExternalIdentity', it is ignored entirely (no create/update/delete). + * - If the payload includes 'ExternalIdentity', it is ignored entirely (no update/delete). * * ## Person (People) handling * - 'Person' is required in the payload. @@ -101,48 +102,6 @@ trait PersonProfileUpsertTrait { - /** - * Normalize inbound Person data: - * - ignore all metadata except meta.id (handled by MessageFilter) - * - enforce co_id from the route - * - * @param array $personRaw - * @param int $coId - * @return array - * @since COmanage Registry v5.3.0 - */ - protected function normalizePersonInbound(array $personRaw, int $coId): array - { - $person = MessageFilter::filterMetadataInbound($personRaw, 'Person'); - $person['co_id'] = $coId; - - return $person; - } - - /** - * Extract Person.id from payload Person.meta.id, if present and valid. - * - * @param array $personRaw - * @return int|null - * @since COmanage Registry v5.3.0 - */ - protected function extractPersonIdFromPayload(array $personRaw): ?int - { - if (!empty($personRaw['meta']) && is_array($personRaw['meta']) && array_key_exists('id', $personRaw['meta'])) { - $id = $personRaw['meta']['id']; - - if (is_int($id)) { - return $id; - } - - if (is_string($id) && ctype_digit($id)) { - return (int)$id; - } - } - - return null; - } - /** * Apply related objects (hasMany + nested role MVEAs) as "replace these sets": * - meta.* ignored except meta.id @@ -176,265 +135,149 @@ protected function applyPersonProfileAssociations(int $personId, int $coId, arra ->firstOrFail(); } - // Address (Person-level) - if (array_key_exists('Address', $payload)) { - $incoming = is_array($payload['Address']) ? $payload['Address'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('Addresses')->getTarget(), - existing: $loadedPerson->addresses ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Addresses.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + foreach ($payload as $model => $modelPayload) { + if ($model === 'Person' || $model === 'ExternalIdentity') { + continue; + } - // AdHocAttribute (Person-level) - if (array_key_exists('AdHocAttribute', $payload)) { - $incoming = is_array($payload['AdHocAttribute']) ? $payload['AdHocAttribute'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('AdHocAttributes')->getTarget(), - existing: $loadedPerson->ad_hoc_attributes ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: null, - fieldMap: [] - ); - } + $incoming = is_array($modelPayload) ? $modelPayload : []; + $models = Inflector::pluralize((string)$model); - // TelephoneNumber (Person-level) - if (array_key_exists('TelephoneNumber', $payload)) { - $incoming = is_array($payload['TelephoneNumber']) ? $payload['TelephoneNumber'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('TelephoneNumbers')->getTarget(), - existing: $loadedPerson->telephone_numbers ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'TelephoneNumbers.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + // Skip unknown/non-owned top-level blocks (eg embedded Group in GroupMember payload) + if (!$People->associations()->has($models)) { + continue; + } - // EmailAddress - if (array_key_exists('EmailAddress', $payload)) { - $incoming = is_array($payload['EmailAddress']) ? $payload['EmailAddress'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('EmailAddresses')->getTarget(), - existing: $loadedPerson->email_addresses ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'EmailAddresses.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + // For People-level existing sets, entity properties are plural + tableized (eg telephone_numbers) + $modelToProperty = Inflector::tableize($models); + + $modelsTable = $People->associations()->get($models)->getTarget(); + + // Use the schema (works regardless of validators/behaviors) + $hasTypeId = $modelsTable->getSchema()->hasColumn('type_id'); + + if ($model === 'PersonRole') { + $roleIdMap = $this->reconcileHasMany( + table: $People->associations()->get($models)->getTarget(), + existing: $loadedPerson->{$modelToProperty} ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'affiliation', + 'attribute' => 'PersonRoles.affiliation_type', + 'targetField' => 'affiliation_type_id', + 'typesTable' => $Types, + ], + fieldMap: [ + 'o' => 'organization', + 'ou' => 'department', + ], + returnsNewIdMap: true + ); + + /** @var \App\Model\Table\PersonRolesTable $PersonRoles */ + $PersonRoles = TableRegistry::getTableLocator()->get('PersonRoles'); + + $existingRolesById = []; + foreach (($loadedPerson->person_roles ?? []) as $er) { + if (!empty($er->id)) { + $existingRolesById[(int)$er->id] = $er; + } + } - // Identifier - if (array_key_exists('Identifier', $payload)) { - $incoming = is_array($payload['Identifier']) ? $payload['Identifier'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('Identifiers')->getTarget(), - existing: $loadedPerson->identifiers ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Identifiers.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + foreach ($incoming as $idx => $incomingRoleRaw) { + if (!is_array($incomingRoleRaw)) { + continue; + } - // Name (payload uses: formatted/prefix ; DB uses: display_name/honorific) - if (array_key_exists('Name', $payload)) { - $incoming = is_array($payload['Name']) ? $payload['Name'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('Names')->getTarget(), - existing: $loadedPerson->names ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Names.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [ - 'formatted' => 'display_name', - 'prefix' => 'honorific', - ] - ); - } + $incomingRole = Utilities::extractInboundId($incomingRoleRaw, 'PersonRole'); + $roleId = $incomingRole['id'] ?? null; - // Url - if (array_key_exists('Url', $payload)) { - $incoming = is_array($payload['Url']) ? $payload['Url'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('Urls')->getTarget(), - existing: $loadedPerson->urls ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Urls.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + if (empty($roleId) && isset($roleIdMap[$idx])) { + $roleId = (int)$roleIdMap[$idx]; + } - // GroupMember (no type mapping; group_id is treated as a normal inbound field here) - if (array_key_exists('GroupMember', $payload)) { - $incoming = is_array($payload['GroupMember']) ? $payload['GroupMember'] : []; - $this->reconcileHasMany( - table: $People->associations()->get('GroupMembers')->getTarget(), - existing: $loadedPerson->group_members ?? [], - incoming: $incoming, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: null, - fieldMap: [] - ); - } + if (empty($roleId)) { + continue; + } - // PersonRole + nested MVEAs - if (array_key_exists('PersonRole', $payload)) { - $incomingRoles = is_array($payload['PersonRole']) ? $payload['PersonRole'] : []; - - $roleIdMap = $this->reconcileHasMany( - table: $People->associations()->get('PersonRoles')->getTarget(), - existing: $loadedPerson->person_roles ?? [], - incoming: $incomingRoles, - parentFk: 'person_id', - parentId: $personId, - coId: $coId, - typeSpec: [ - 'field' => 'affiliation', - 'attribute' => 'PersonRoles.affiliation_type', - 'targetField' => 'affiliation_type_id', - 'typesTable' => $Types, - ], - fieldMap: [ - 'o' => 'organization', - 'ou' => 'department', - ], - returnsNewIdMap: true - ); - - /** @var \App\Model\Table\PersonRolesTable $PersonRoles */ - $PersonRoles = TableRegistry::getTableLocator()->get('PersonRoles'); - - $existingRolesById = []; - foreach (($loadedPerson->person_roles ?? []) as $er) { - if (!empty($er->id)) { - $existingRolesById[(int)$er->id] = $er; - } - } + $existingRoleEntity = $existingRolesById[(int)$roleId] ?? null; - foreach ($incomingRoles as $idx => $incomingRoleRaw) { - if (!is_array($incomingRoleRaw)) { - continue; - } + // Reconcile role-level children dynamically for any nested association key present in payload + foreach ($incomingRoleRaw as $childKey => $childPayload) { + // Skip metadata/scalars; only nested association blocks are arrays (eg Address, Url, Identifier, etc) + if ($childKey === 'meta' || !is_array($childPayload)) { + continue; + } - $incomingRole = Utilities::extractInboundId($incomingRoleRaw, 'PersonRole'); - $roleId = $incomingRole['id'] ?? null; + $childAssoc = Inflector::pluralize((string)$childKey); - if (empty($roleId) && isset($roleIdMap[$idx])) { - $roleId = (int)$roleIdMap[$idx]; - } + if (!$PersonRoles->associations()->has($childAssoc)) { + continue; + } - if (empty($roleId)) { - continue; - } + $incomingChildren = $childPayload; - $existingRoleEntity = $existingRolesById[(int)$roleId] ?? null; - - // Address under PersonRole - if (array_key_exists('Address', $incomingRoleRaw)) { - $incomingAddresses = is_array($incomingRoleRaw['Address']) ? $incomingRoleRaw['Address'] : []; - $this->reconcileHasMany( - table: $PersonRoles->associations()->get('Addresses')->getTarget(), - existing: $existingRoleEntity->addresses ?? [], - incoming: $incomingAddresses, - parentFk: 'person_role_id', - parentId: (int)$roleId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Addresses.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + $childTable = $PersonRoles->associations()->get($childAssoc)->getTarget(); + $childHasTypeId = $childTable->getSchema()->hasColumn('type_id'); - // AdHocAttribute under PersonRole - if (array_key_exists('AdHocAttribute', $incomingRoleRaw)) { - $incomingAttrs = is_array($incomingRoleRaw['AdHocAttribute']) ? $incomingRoleRaw['AdHocAttribute'] : []; - $this->reconcileHasMany( - table: $PersonRoles->associations()->get('AdHocAttributes')->getTarget(), - existing: $existingRoleEntity->ad_hoc_attributes ?? [], - incoming: $incomingAttrs, - parentFk: 'person_role_id', - parentId: (int)$roleId, - coId: $coId, - typeSpec: null, - fieldMap: [] - ); - } + $childProp = Inflector::tableize($childAssoc); - // TelephoneNumber under PersonRole - if (array_key_exists('TelephoneNumber', $incomingRoleRaw)) { - $incomingTels = is_array($incomingRoleRaw['TelephoneNumber']) ? $incomingRoleRaw['TelephoneNumber'] : []; - $this->reconcileHasMany( - table: $PersonRoles->associations()->get('TelephoneNumbers')->getTarget(), - existing: $existingRoleEntity->telephone_numbers ?? [], - incoming: $incomingTels, - parentFk: 'person_role_id', - parentId: (int)$roleId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'TelephoneNumbers.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); + $typeSpec = null; + if ($childHasTypeId) { + $typeSpec = [ + 'field' => 'type', + 'attribute' => $childAssoc . '.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ]; + } + + $this->reconcileHasMany( + table: $childTable, + existing: $existingRoleEntity->{$childProp} ?? [], + incoming: $incomingChildren, + parentFk: 'person_role_id', + parentId: (int)$roleId, + coId: $coId, + typeSpec: $typeSpec, + fieldMap: [] + ); + } } + + continue; + } + + if ($hasTypeId) { + $this->reconcileHasMany( + table: $People->associations()->get($models)->getTarget(), + existing: $loadedPerson->{$modelToProperty} ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: [ + 'field' => 'type', + 'attribute' => $models . '.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ], + fieldMap: [] + ); + } else { + $this->reconcileHasMany( + table: $People->associations()->get($models)->getTarget(), + existing: $loadedPerson->{$modelToProperty} ?? [], + incoming: $incoming, + parentFk: 'person_id', + parentId: $personId, + coId: $coId, + typeSpec: null, + fieldMap: [] + ); } } } diff --git a/app/plugins/CoreApi/src/Lib/Utils/Utilities.php b/app/plugins/CoreApi/src/Lib/Utils/Utilities.php index c007a6a7d..b8c99bba5 100644 --- a/app/plugins/CoreApi/src/Lib/Utils/Utilities.php +++ b/app/plugins/CoreApi/src/Lib/Utils/Utilities.php @@ -85,6 +85,30 @@ public static function extractInboundId(array $raw): array return $raw; } + /** + * Extract Person.id from payload Person.meta.id, if present and valid. + * + * @param array $personRaw + * @return int|null + * @since COmanage Registry v5.3.0 + */ + public static function extractPersonIdFromPayload(array $personRaw): ?int + { + if (!empty($personRaw['meta']) && is_array($personRaw['meta']) && array_key_exists('id', $personRaw['meta'])) { + $id = $personRaw['meta']['id']; + + if (is_int($id)) { + return $id; + } + + if (is_string($id) && ctype_digit($id)) { + return (int)$id; + } + } + + return null; + } + /** * Obtain the People table. * From 121b974ff084a5516448e3b0bcb16ffacbcf9d86 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jul 2026 13:19:06 +0000 Subject: [PATCH 15/16] Create external identity: dynamic calculation of contain. --- .../Traits/ExternalIdentityCreateTrait.php | 379 ++++++++++-------- 1 file changed, 215 insertions(+), 164 deletions(-) diff --git a/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php index 20e7b35b4..c6f008d66 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php @@ -32,11 +32,20 @@ use Cake\Datasource\Exception\RecordNotFoundException; use Cake\Http\Exception\BadRequestException; use Cake\ORM\Exception\PersistenceFailedException; +use Cake\ORM\Table; +use Cake\ORM\TableRegistry; +use Cake\Utility\Inflector; use CoreApi\Lib\Utils\MessageFilter; use CoreApi\Lib\Utils\Utilities; trait ExternalIdentityCreateTrait { + protected const EXTERNAL_IDENTITY_CONTAIN_BLACKLIST = [ + 'ExtIdentitySourceRecords', + 'HistoryRecords', + 'JobHistoryRecords', + ]; + /** * Create ExternalIdentity records (and their associations) from a Person Profile API payload. * @@ -92,7 +101,7 @@ protected function createExternalIdentityWithAssociations(int $personId, int $co $ei = Utilities::normalizeInboundDateTimes($ei); $ei['person_id'] = $personId; - // Required field: status (your error shows it cannot be empty) + // Required field: status if (!array_key_exists('status', $ei) || $ei['status'] === null || $ei['status'] === '') { $ei['status'] = 'A'; } @@ -137,196 +146,238 @@ protected function applyExternalIdentityAssociations(int $externalIdentityId, in $ExternalIdentities = $People->associations()->get('ExternalIdentities')->getTarget(); - // Load existing sets for reconcile*() (empty is fine too) + // Load existing sets for reconcile*() using model associations (no hardcoding) + $contain = $this->getExternalIdentityContain($ExternalIdentities); + $loadedEi = $ExternalIdentities->find() ->where(['ExternalIdentities.id' => $externalIdentityId]) - ->contain([ - 'Addresses', - 'AdHocAttributes', - 'EmailAddresses', - 'Identifiers', - 'Names', - 'Pronouns', - 'TelephoneNumbers', - 'Urls', - 'ExternalIdentityRoles', - ]) + ->contain($contain) ->firstOrFail(); - // Address - if (array_key_exists('Address', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['Address']) ? $externalIdentityRaw['Address'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('Addresses')->getTarget(), - existing: $loadedEi->addresses ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Addresses.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + foreach ($externalIdentityRaw as $model => $modelPayload) { + // Skip metadata/scalars; only nested association blocks are arrays + if ($model === 'meta' || !is_array($modelPayload)) { + continue; + } - // AdHocAttribute - if (array_key_exists('AdHocAttribute', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['AdHocAttribute']) ? $externalIdentityRaw['AdHocAttribute'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('AdHocAttributes')->getTarget(), - existing: $loadedEi->ad_hoc_attributes ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: null, - fieldMap: [] - ); - } + $incoming = $modelPayload; + $models = Inflector::pluralize((string)$model); - // EmailAddress - if (array_key_exists('EmailAddress', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['EmailAddress']) ? $externalIdentityRaw['EmailAddress'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('EmailAddresses')->getTarget(), - existing: $loadedEi->email_addresses ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'EmailAddresses.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + // Skip unknown/non-owned blocks + if (!$ExternalIdentities->associations()->has($models)) { + continue; + } - // Identifier - if (array_key_exists('Identifier', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['Identifier']) ? $externalIdentityRaw['Identifier'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('Identifiers')->getTarget(), - existing: $loadedEi->identifiers ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Identifiers.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + $modelsAssoc = $ExternalIdentities->associations()->get($models); + $modelsTable = $modelsAssoc->getTarget(); + + // For EI-level existing sets, entity properties are plural + tableized + $modelToProperty = Inflector::tableize($models); + + // Use the schema (works regardless of validators/behaviors) + $hasTypeId = $modelsTable->getSchema()->hasColumn('type_id'); + + // ExternalIdentityRole is special: affiliation => affiliation_type_id, and supports nested role MVEAs + if ($model === 'ExternalIdentityRole') { + $roleIdMap = $this->reconcileExternalIdentityHasMany( + table: $modelsTable, + existing: $loadedEi->{$modelToProperty} ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: [ + 'field' => 'affiliation', + // IMPORTANT: types are configured under PersonRoles.affiliation_type, not ExternalIdentityRoles.affiliation_type + 'attribute' => 'PersonRoles.affiliation_type', + 'targetField' => 'affiliation_type_id', + 'typesTable' => $Types, + ], + fieldMap: [ + 'o' => 'organization', + 'ou' => 'department', + ], + returnsNewIdMap: true + ); + + /** @var \App\Model\Table\ExternalIdentityRolesTable $ExternalIdentityRoles */ + $ExternalIdentityRoles = TableRegistry::getTableLocator()->get('ExternalIdentityRoles'); + + $existingRolesById = []; + foreach (($loadedEi->external_identity_roles ?? []) as $er) { + if (!empty($er->id)) { + $existingRolesById[(int)$er->id] = $er; + } + } - // Name - if (array_key_exists('Name', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['Name']) ? $externalIdentityRaw['Name'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('Names')->getTarget(), - existing: $loadedEi->names ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ + foreach ($incoming as $idx => $incomingRoleRaw) { + if (!is_array($incomingRoleRaw)) { + continue; + } + + $incomingRole = Utilities::extractInboundId($incomingRoleRaw, 'ExternalIdentityRole'); + $roleId = $incomingRole['id'] ?? null; + + if (empty($roleId) && isset($roleIdMap[$idx])) { + $roleId = (int)$roleIdMap[$idx]; + } + + if (empty($roleId)) { + continue; + } + + $existingRoleEntity = $existingRolesById[(int)$roleId] ?? null; + + // Reconcile role-level children dynamically for any nested association key present in payload + foreach ($incomingRoleRaw as $childKey => $childPayload) { + if ($childKey === 'meta' || !is_array($childPayload)) { + continue; + } + + $childAssoc = Inflector::pluralize((string)$childKey); + + if (!$ExternalIdentityRoles->associations()->has($childAssoc)) { + continue; + } + + $childTable = $ExternalIdentityRoles->associations()->get($childAssoc)->getTarget(); + $childHasTypeId = $childTable->getSchema()->hasColumn('type_id'); + $childProp = Inflector::tableize($childAssoc); + + $typeSpec = null; + if ($childHasTypeId) { + $typeSpec = [ + 'field' => 'type', + 'attribute' => $childAssoc . '.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ]; + } + + $this->reconcileExternalIdentityHasMany( + table: $childTable, + existing: $existingRoleEntity->{$childProp} ?? [], + incoming: $childPayload, + parentFk: 'external_identity_role_id', + parentId: (int)$roleId, + coId: $coId, + typeSpec: $typeSpec, + fieldMap: [] + ); + } + } + + continue; + } + + $typeSpec = null; + if ($hasTypeId) { + $typeSpec = [ 'field' => 'type', - 'attribute' => 'Names.type', + 'attribute' => $models . '.type', 'targetField' => 'type_id', 'typesTable' => $Types, - ], - fieldMap: [] - ); - } + ]; + } - // Pronoun - if (array_key_exists('Pronoun', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['Pronoun']) ? $externalIdentityRaw['Pronoun'] : []; $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('Pronouns')->getTarget(), - existing: $loadedEi->pronouns ?? [], + table: $modelsTable, + existing: $loadedEi->{$modelToProperty} ?? [], incoming: $incoming, parentFk: 'external_identity_id', parentId: $externalIdentityId, coId: $coId, - typeSpec: null, + typeSpec: $typeSpec, fieldMap: [] ); } + } - // TelephoneNumber - if (array_key_exists('TelephoneNumber', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['TelephoneNumber']) ? $externalIdentityRaw['TelephoneNumber'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('TelephoneNumbers')->getTarget(), - existing: $loadedEi->telephone_numbers ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'TelephoneNumbers.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); + /** + * Build the contain graph for ExternalIdentity loads for reconciliation. + * + * This keeps the logic intentionally simple: + * - include all ExternalIdentities hasOne/hasMany associations + * - for ExternalIdentityRoles, include their hasOne/hasMany associations as a second level + * + * @param Table $externalIdentitiesTable + * @return array + * @since COmanage Registry v5.3.0 + */ + protected function getExternalIdentityContain(Table $externalIdentitiesTable): array + { + $contain = $this->getOwnedAssociationNames($externalIdentitiesTable); + + if ($externalIdentitiesTable->associations()->has('ExternalIdentityRoles')) { + $externalIdentityRolesTarget = $externalIdentitiesTable->associations()->get('ExternalIdentityRoles')->getTarget(); + + $externalIdentityRolesContain = $this->getOwnedAssociationNames($externalIdentityRolesTarget); + + // Do NOT include the PersonRoles hasOne here. In ExternalIdentityRolesTable this is + // exposed via property "pipelined_person_role" and becomes "PipelinedPersonRole" in output. + $externalIdentityRolesContain = array_values(array_filter( + $externalIdentityRolesContain, + static fn(string $name): bool => $name !== 'PersonRoles' + )); + + $contain = $this->replaceContainEntry($contain, 'ExternalIdentityRoles', $externalIdentityRolesContain); } - // Url - if (array_key_exists('Url', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['Url']) ? $externalIdentityRaw['Url'] : []; - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('Urls')->getTarget(), - existing: $loadedEi->urls ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ - 'field' => 'type', - 'attribute' => 'Urls.type', - 'targetField' => 'type_id', - 'typesTable' => $Types, - ], - fieldMap: [] - ); + return $contain; + } + + /** + * Return the names of hasMany/hasOne associations for a table. + * + * @param Table $table + * @return array + * @since COmanage Registry v5.3.0 + */ + protected function getOwnedAssociationNames(Table $table): array + { + $names = []; + + $associations = $table->associations()->getByType(['hasMany', 'hasOne']); + + foreach ($associations as $assoc) { + $asocName = $assoc->getName(); + if (!in_array($asocName, self::EXTERNAL_IDENTITY_CONTAIN_BLACKLIST, true)) { + $names[] = $asocName; + } } - // ExternalIdentityRole (create-only, no nested role MVEAs yet) - if (array_key_exists('ExternalIdentityRole', $externalIdentityRaw)) { - $incoming = is_array($externalIdentityRaw['ExternalIdentityRole']) ? $externalIdentityRaw['ExternalIdentityRole'] : []; + sort($names); - $this->reconcileExternalIdentityHasMany( - table: $ExternalIdentities->associations()->get('ExternalIdentityRoles')->getTarget(), - existing: $loadedEi->external_identity_roles ?? [], - incoming: $incoming, - parentFk: 'external_identity_id', - parentId: $externalIdentityId, - coId: $coId, - typeSpec: [ - 'field' => 'affiliation', - // IMPORTANT: types are configured under PersonRoles.affiliation_type, not ExternalIdentityRoles.affiliation_type - 'attribute' => 'PersonRoles.affiliation_type', - 'targetField' => 'affiliation_type_id', - 'typesTable' => $Types, - ], - fieldMap: [ - 'o' => 'organization', - 'ou' => 'department', - ] - ); + return $names; + } + + /** + * Replace a top-level contain entry (eg "ExternalIdentityRoles") with a nested contain definition. + * + * Works whether the entry is a numeric element ("ExternalIdentityRoles") or already keyed. + * + * @param array $contain + * @param string $name + * @param array $nested + * @return array + * @since COmanage Registry v5.3.0 + */ + protected function replaceContainEntry(array $contain, string $name, array $nested): array + { + $out = []; + foreach ($contain as $k => $v) { + if (is_int($k) && $v === $name) { + continue; + } + if (is_string($k) && $k === $name) { + continue; + } + $out[$k] = $v; } + + $out[$name] = $nested; + + return $out; } /** From 8b574b017baa3c92c8ebcf736f53672f3830ffc7 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 9 Jul 2026 14:02:14 +0000 Subject: [PATCH 16/16] refactor according to DRY --- .../Traits/ExternalIdentityCreateTrait.php | 87 +----------- .../Lib/Traits/PersonProfileUpsertTrait.php | 2 +- .../src/Lib/Utils/ContainGraphBuilder.php | 132 ++++++++++++++++++ .../src/Model/Table/PersonProfilesTable.php | 129 ++++------------- 4 files changed, 169 insertions(+), 181 deletions(-) create mode 100644 app/plugins/CoreApi/src/Lib/Utils/ContainGraphBuilder.php diff --git a/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php index c6f008d66..617299629 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php @@ -1,6 +1,6 @@ getOwnedAssociationNames($externalIdentitiesTable); - - if ($externalIdentitiesTable->associations()->has('ExternalIdentityRoles')) { - $externalIdentityRolesTarget = $externalIdentitiesTable->associations()->get('ExternalIdentityRoles')->getTarget(); - - $externalIdentityRolesContain = $this->getOwnedAssociationNames($externalIdentityRolesTarget); - - // Do NOT include the PersonRoles hasOne here. In ExternalIdentityRolesTable this is - // exposed via property "pipelined_person_role" and becomes "PipelinedPersonRole" in output. - $externalIdentityRolesContain = array_values(array_filter( - $externalIdentityRolesContain, - static fn(string $name): bool => $name !== 'PersonRoles' - )); - - $contain = $this->replaceContainEntry($contain, 'ExternalIdentityRoles', $externalIdentityRolesContain); - } - - return $contain; - } - - /** - * Return the names of hasMany/hasOne associations for a table. - * - * @param Table $table - * @return array - * @since COmanage Registry v5.3.0 - */ - protected function getOwnedAssociationNames(Table $table): array - { - $names = []; - - $associations = $table->associations()->getByType(['hasMany', 'hasOne']); - - foreach ($associations as $assoc) { - $asocName = $assoc->getName(); - if (!in_array($asocName, self::EXTERNAL_IDENTITY_CONTAIN_BLACKLIST, true)) { - $names[] = $asocName; - } - } - - sort($names); - - return $names; - } - - /** - * Replace a top-level contain entry (eg "ExternalIdentityRoles") with a nested contain definition. - * - * Works whether the entry is a numeric element ("ExternalIdentityRoles") or already keyed. - * - * @param array $contain - * @param string $name - * @param array $nested - * @return array - * @since COmanage Registry v5.3.0 - */ - protected function replaceContainEntry(array $contain, string $name, array $nested): array - { - $out = []; - foreach ($contain as $k => $v) { - if (is_int($k) && $v === $name) { - continue; - } - if (is_string($k) && $k === $name) { - continue; - } - $out[$k] = $v; - } - - $out[$name] = $nested; - - return $out; + return ContainGraphBuilder::buildExternalIdentityContain($externalIdentitiesTable); } /** diff --git a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php index 49865756e..c14010b78 100644 --- a/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -1,6 +1,6 @@ + */ + public const EXTERNAL_IDENTITY_CONTAIN_BLACKLIST = [ + 'ExtIdentitySourceRecords', + 'HistoryRecords', + 'JobHistoryRecords', + ]; + + /** + * Build the ExternalIdentities contain graph: + * - include all hasOne/hasMany associations except blacklist + * - include ExternalIdentityRoles children as a second level + * - exclude ExternalIdentityRoles->PersonRoles (pipelined role) + * + * @param Table $externalIdentitiesTable + * @param array|null $blacklist + * @return array + */ + public static function buildExternalIdentityContain(Table $externalIdentitiesTable, ?array $blacklist = null): array + { + $blacklist = $blacklist ?? self::EXTERNAL_IDENTITY_CONTAIN_BLACKLIST; + + $contain = self::getOwnedAssociationNames($externalIdentitiesTable, $blacklist); + + if ($externalIdentitiesTable->associations()->has('ExternalIdentityRoles')) { + $externalIdentityRolesTarget = $externalIdentitiesTable->associations()->get('ExternalIdentityRoles')->getTarget(); + + $externalIdentityRolesContain = self::getOwnedAssociationNames($externalIdentityRolesTarget, $blacklist); + + // Do NOT include the PersonRoles hasOne here (pipelined role). + $externalIdentityRolesContain = array_values(array_filter( + $externalIdentityRolesContain, + static fn(string $name): bool => $name !== 'PersonRoles' + )); + + $contain = self::replaceContainEntry($contain, 'ExternalIdentityRoles', $externalIdentityRolesContain); + } + + return $contain; + } + + /** + * Return the names of hasMany/hasOne associations for a table, excluding $blacklist. + * + * @param Table $table + * @param array $blacklist + * @return array + */ + public static function getOwnedAssociationNames(Table $table, array $blacklist): array + { + $names = []; + + $associations = $table->associations()->getByType(['hasMany', 'hasOne']); + + foreach ($associations as $assoc) { + $assocName = $assoc->getName(); + if (!in_array($assocName, $blacklist, true)) { + $names[] = $assocName; + } + } + + sort($names); + + return $names; + } + + /** + * Replace a top-level contain entry (eg "PersonRoles") with a nested contain definition. + * + * Works whether the entry is a numeric element ("PersonRoles") or already keyed. + * + * @param array $contain + * @param string $name + * @param array $nested + * @return array + */ + public static function replaceContainEntry(array $contain, string $name, array $nested): array + { + $out = []; + foreach ($contain as $k => $v) { + if (is_int($k) && $v === $name) { + continue; + } + if (is_string($k) && $k === $name) { + continue; + } + $out[$k] = $v; + } + + $out[$name] = $nested; + + return $out; + } +} diff --git a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php index e02b34834..ea7a82e07 100644 --- a/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -34,6 +34,7 @@ use Cake\ORM\TableRegistry; use Cake\Validation\Validator; use CoreApi\Lib\Enum\ResponseTypesEnum; +use CoreApi\Lib\Utils\ContainGraphBuilder; class PersonProfilesTable extends Table { @@ -50,7 +51,11 @@ class PersonProfilesTable extends Table /** * Associations that must never be included in Person Profile contain graphs. * + * This blacklist is applied when dynamically including "owned" associations (hasOne/hasMany) + * from People and certain child tables. + * * @var array + * @since COmanage Registry v5.3.0 */ protected const PERSON_PROFILE_CONTAIN_BLACKLIST = [ 'ActorNotifications', @@ -69,8 +74,9 @@ class PersonProfilesTable extends Table /** * Perform Cake Model initialization. * - * @since COmanage Registry v5.3.0 - * @param array $config Configuration options passed to constructor + * @param array $config Configuration options passed to constructor + * @return void + * @since COmanage Registry v5.3.0 */ public function initialize(array $config): void { @@ -136,11 +142,12 @@ public function initialize(array $config): void } /** - * Find a Person record scoped to CO, including all associations required to build a Person Profile message. + * Find a Person record scoped to CO, including all associations required to build + * a Person Profile message. * - * @param int $coId - * @param int $personId - * @return EntityInterface + * @param int $coId CO ID (scope constraint) + * @param int $personId People.id + * @return EntityInterface Person entity (PeopleTable) with contain graph loaded * @since COmanage Registry v5.3.0 */ public function findPersonWithProfileContain(int $coId, int $personId): EntityInterface @@ -159,12 +166,12 @@ public function findPersonWithProfileContain(int $coId, int $personId): EntityIn /** * Build the contain graph for Person Profile reads from PeopleTable associations. * - * This keeps the logic intentionally simple: - * - include all People hasOne/hasMany associations - * - for a few key associations, include their hasOne/hasMany associations as a second level - * - add minimal "glue" contain where needed (eg GroupMembers -> Groups) + * Rules: + * - include all People hasOne/hasMany associations except blacklist + * - for a few "container" relations (eg PersonRoles), include one additional level + * - add minimal glue contain where needed (eg GroupMembers -> Groups) * - * @return array + * @return array CakePHP contain graph suitable for Query::contain() * @since COmanage Registry v5.3.0 */ public function getPersonProfileContain(): array @@ -172,11 +179,11 @@ public function getPersonProfileContain(): array /** @var \App\Model\Table\PeopleTable $People */ $People = TableRegistry::getTableLocator()->get('People'); - $contain = $this->getOwnedAssociationNames($People); + $contain = ContainGraphBuilder::getOwnedAssociationNames($People, self::PERSON_PROFILE_CONTAIN_BLACKLIST); // GroupMembers is not very useful without the Group record if (in_array('GroupMembers', $contain, true) && $People->associations()->has('GroupMembers')) { - $contain = $this->replaceContainEntry($contain, 'GroupMembers', ['Groups']); + $contain = ContainGraphBuilder::replaceContainEntry($contain, 'GroupMembers', ['Groups']); } // Add one level of children for key "container" relations. @@ -185,106 +192,32 @@ public function getPersonProfileContain(): array if ($People->associations()->has('PersonRoles')) { $personRolesTarget = $People->associations()->get('PersonRoles')->getTarget(); - $personRolesContain = $this->getOwnedAssociationNames($personRolesTarget); - - $contain = $this->replaceContainEntry( - $contain, - 'PersonRoles', - $personRolesContain + $personRolesContain = ContainGraphBuilder::getOwnedAssociationNames( + $personRolesTarget, + self::PERSON_PROFILE_CONTAIN_BLACKLIST ); + + $contain = ContainGraphBuilder::replaceContainEntry($contain, 'PersonRoles', $personRolesContain); } + // ExternalIdentities are included as a nested subtree built by ContainGraphBuilder if ($People->associations()->has('ExternalIdentities')) { $externalIdentitiesTarget = $People->associations()->get('ExternalIdentities')->getTarget(); - $externalIdentitiesContain = $this->getOwnedAssociationNames($externalIdentitiesTarget); - - // ExternalIdentity affiliation/title/etc comes from ExternalIdentityRoles - if ($externalIdentitiesTarget->associations()->has('ExternalIdentityRoles')) { - $externalIdentityRolesTarget = $externalIdentitiesTarget->associations()->get('ExternalIdentityRoles')->getTarget(); - - // Pull all owned children for ExternalIdentityRoles - $externalIdentityRolesContain = $this->getOwnedAssociationNames($externalIdentityRolesTarget); - - // Do NOT include the PersonRoles hasOne here. In ExternalIdentityRolesTable this is - // exposed via property "pipelined_person_role" and becomes "PipelinedPersonRole" in output. - $externalIdentityRolesContain = array_values(array_filter( - $externalIdentityRolesContain, - static fn(string $name): bool => $name !== 'PersonRoles' - )); - - $externalIdentitiesContain['ExternalIdentityRoles'] = $externalIdentityRolesContain; - } + $externalIdentitiesContain = ContainGraphBuilder::buildExternalIdentityContain($externalIdentitiesTarget); - $contain = $this->replaceContainEntry( - $contain, - 'ExternalIdentities', - $externalIdentitiesContain - ); + $contain = ContainGraphBuilder::replaceContainEntry($contain, 'ExternalIdentities', $externalIdentitiesContain); } return $contain; } - /** - * Return the names of hasMany/hasOne associations for a table. - * - * @param Table $table - * @return array - */ - protected function getOwnedAssociationNames(Table $table): array - { - $names = []; - - $associations = $table->associations()->getByType(['hasMany', 'hasOne']); - - foreach ($associations as $assoc) { - $asocName = $assoc->getName(); - if (!in_array($asocName, self::PERSON_PROFILE_CONTAIN_BLACKLIST, true)) { - $names[] = $asocName; - } - } - - sort($names); - - return $names; - } - - /** - * Replace a top-level contain entry (eg "PersonRoles") with a nested contain definition. - * - * Works whether the entry is a numeric element ("PersonRoles") or already keyed. - * - * @param array $contain - * @param string $name - * @param array $nested - * @return array - */ - protected function replaceContainEntry(array $contain, string $name, array $nested): array - { - // remove numeric occurrence(s) - $out = []; - foreach ($contain as $k => $v) { - if (is_int($k) && $v === $name) { - continue; - } - if (is_string($k) && $k === $name) { - continue; - } - $out[$k] = $v; - } - - $out[$name] = $nested; - - return $out; - } - /** * Table specific logic to generate a display field. * - * @since COmanage Registry v5.2.0 * @param \CoreApi\Model\Entity\PersonProfile $entity Entity to generate display field for * @return string Display field + * @since COmanage Registry v5.3.0 */ public function generateDisplayField(\CoreApi\Model\Entity\PersonProfile $entity): string { @@ -294,9 +227,9 @@ public function generateDisplayField(\CoreApi\Model\Entity\PersonProfile $entity /** * Set validation rules. * - * @since COmanage Registry v5.3.0 - * @param Validator $validator Validator + * @param Validator $validator Validator * @return Validator Validator + * @since COmanage Registry v5.3.0 */ public function validationDefault(Validator $validator): Validator {