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/message.json b/app/plugins/CoreApi/config/message.json new file mode 100644 index 000000000..40915a119 --- /dev/null +++ b/app/plugins/CoreApi/config/message.json @@ -0,0 +1,426 @@ +{ + "$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", "null"], + "format": "date" + }, + "status": { + "description": "Person status", + "type": "string" + }, + "timezone": { + "description": "Preferred timezone of this Person, for UI purposes", + "type": ["string", "null"] + } + }, + "required": ["co_id", "status"], + "additionalProperties": false + }, + "GroupMember": { + "type": "array", + "description": "Memberships of the Person in Groups.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "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", "null"] + }, + "owner": { + "description": "If this Person is an owner of this group", + "type": ["boolean", "null"] + }, + "group_nesting_id": { + "description": "Group nesting that created this membership, if set", + "type": ["integer", "null"], + "readOnly": true + } + }, + "required": ["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", "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", "null"], + "format": "date-time" + }, + "valid_through": { + "description": "Valid through time for this Role", + "type": ["string", "null"], + "format": "date-time" + }, + "status": { "description": "Person Role status", "type": ["string", "null"] }, + "sponsor_person_id": { + "description": "Sponsor Person ID for this Role", + "type": ["integer", "null"] + }, + "affiliation": { "description": "Person Role affiliation", "type": ["string", "null"] }, + "ordr": { + "description": "Order of this Role, relative to other roles for this person", + "type": ["integer", "null"] + }, + "Address": { + "type": "array", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "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", "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", "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": [], + "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", "null"], + "format": "email" + }, + "type": { "description": "The type of Email Address", "type": ["string", "null"] }, + "verified": { + "description": "Whether this Email Address has been verified", + "type": ["boolean", "null"] + } + }, + "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", "null"] }, + "login": { + "description": "Whether this Identifier can be used to login to Registry", + "type": ["boolean", "null"] + }, + "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.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "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.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "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.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "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", "null"], + "readOnly": true + } + }, + "required": [], + "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", "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", "null"], + "readOnly": true + } + }, + "required": [], + "additionalProperties": false + } + }, + "Password": { + "type": "array", + "description": "Passwords for the Person.", + "items": { + "type": "object", + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "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", "null"], + "readOnly": true + } + }, + "required": [], + "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", "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", "null"], + "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", "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" }, + "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..32e23fa86 --- /dev/null +++ b/app/plugins/CoreApi/config/openapi yaml @@ -0,0 +1,937 @@ +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 (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 + 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 + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedId" + "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: + "204": + description: No Content (deleted) + "401": + description: Unauthorized + "404": + description: Not Found + "500": + description: Server Error + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + + schemas: + MetaRead: + type: object + description: | + Metadata returned on read (GET). + 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 + + MetaWrite: + type: object + 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/MetaRead" + co_id: + description: CO ID for this Person + type: integer + readOnly: true + 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 + 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 + nullable: true + required: [status] + additionalProperties: false + + Name: + type: array + items: + type: object + description: Name for a Person + properties: + 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 + nullable: true + additionalProperties: false + + EmailAddress: + type: array + items: + type: object + description: Email address for a Person + properties: + 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 + nullable: true + additionalProperties: false + + Identifier: + type: array + items: + type: object + description: Identifier for a Person + properties: + 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 + nullable: true + additionalProperties: false + + Url: + type: array + items: + type: object + description: URL for a Person + properties: + 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 + nullable: true + additionalProperties: false + + GroupMember: + type: array + items: + type: object + description: Membership of Person in a Group + properties: + meta: + $ref: "#/components/schemas/MetaWrite" + group_id: + description: Group ID for this membership + type: integer + 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 + nullable: true + group_nesting_id: + description: Group nesting that created this membership, if set + type: integer + nullable: true + readOnly: true + required: [group_id] + additionalProperties: false + + Address: + type: array + items: + type: object + description: Postal address + properties: + 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 + 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 + nullable: true + additionalProperties: false + + AdHocAttribute: + type: array + items: + type: object + description: An ad-hoc attribute + properties: + 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 + + TelephoneNumber: + type: array + items: + type: object + description: Telephone number + properties: + 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 + + PersonRoleRead: + type: array + items: + type: object + description: Role for a Person (read form) + properties: + 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 + nullable: true + valid_through: + description: Valid through time for this Role + type: string + 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: + $ref: "#/components/schemas/AdHocAttribute" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" + required: [affiliation, status] + additionalProperties: false + + PersonRoleWrite: + type: array + items: + type: object + description: Role for a Person (write form) + properties: + meta: + $ref: "#/components/schemas/MetaWrite" + 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: + $ref: "#/components/schemas/AdHocAttribute" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" + required: [affiliation, status] + additionalProperties: false + + ExternalIdentityRoleRead: + type: array + items: + type: object + description: Role associated with an External Identity (read-only) + properties: + meta: + $ref: "#/components/schemas/MetaRead" + title: + description: Title for this External Identity Role + type: string + nullable: true + o: + description: Organization for this External Identity Role + type: string + nullable: true + ou: + description: Department for this External Identity Role + type: string + nullable: true + valid_from: + description: Valid from time for this External Identity Role + type: string + nullable: true + valid_through: + description: Valid through time for this External Identity Role + type: string + nullable: true + status: + description: External Identity Role status + type: string + nullable: true + affiliation: + description: External Identity Role affiliation + type: string + nullable: true + ordr: + description: Order for this External Identity Role + type: integer + nullable: true + Address: + $ref: "#/components/schemas/Address" + AdHocAttribute: + $ref: "#/components/schemas/AdHocAttribute" + TelephoneNumber: + $ref: "#/components/schemas/TelephoneNumber" + additionalProperties: false + + 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: External identity (read-only) + properties: + meta: + $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 + nullable: true + o: + description: Organization for this External Identity + type: string + nullable: true + ou: + description: Department for this External Identity + type: string + nullable: true + valid_from: + description: Valid from time for this External Identity + type: string + nullable: true + valid_through: + description: Valid through time for this External Identity + type: string + nullable: true + status: + description: External Identity status + type: string + nullable: true + affiliation: + description: External Identity affiliation + type: string + nullable: true + date_of_birth: + description: External Identity date of birth + type: string + 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 + required: [id] + additionalProperties: false + + PersonProfileMessageRead: + type: object + description: Collection of a Person and related objects (read form) + properties: + Person: + $ref: "#/components/schemas/PersonRead" + GroupMember: + $ref: "#/components/schemas/GroupMember" + EmailAddress: + $ref: "#/components/schemas/EmailAddress" + Identifier: + $ref: "#/components/schemas/Identifier" + Name: + $ref: "#/components/schemas/Name" + Url: + $ref: "#/components/schemas/Url" + PersonRole: + $ref: "#/components/schemas/PersonRoleRead" + ExternalIdentity: + $ref: "#/components/schemas/ExternalIdentityRead" + required: [Person] + additionalProperties: false + + PersonProfileMessageWrite: + type: object + description: | + Collection of a Person and related objects (write form). + + 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/PersonWrite" + GroupMember: + $ref: "#/components/schemas/GroupMember" + EmailAddress: + $ref: "#/components/schemas/EmailAddress" + Identifier: + $ref: "#/components/schemas/Identifier" + Name: + $ref: "#/components/schemas/Name" + Url: + $ref: "#/components/schemas/Url" + PersonRole: + $ref: "#/components/schemas/PersonRoleWrite" + required: [Person] + additionalProperties: false + + responses: + PagedPersonProfileMessage: + 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: + currentPage: + description: current page + type: string + readOnly: true + example: "1" + itemsPerPage: + description: items per page + type: string + readOnly: true + example: "100" + 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: + $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 new file mode 100644 index 000000000..cfdb258c3 --- /dev/null +++ b/app/plugins/CoreApi/config/person_profiles_routes.php @@ -0,0 +1,123 @@ += 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'] + ) + ->setPass(['coid']) + ->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'] + ) + ->setPass(['coid']) + ->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'] + ) + ->setPass(['coid', 'identifier']) + ->setPatterns([ + 'coid' => '[0-9]+', + 'identifier' => '[^/]+' + ]); + + // 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'] + ) + ->setPass(['coid', 'identifier']) + ->setPatterns([ + 'coid' => '[0-9]+', + 'identifier' => '[^/]+' + ]); + + // 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'] + ) + ->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..6d3b82dfe 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,20 @@ "match_callbacks_i1": { "columns": [ "api_id" ]}, "match_callbacks_i2": { "needed": false, "columns": [ "server_id" ]} } + }, + "person_profiles": { + "columns": { + "id": {}, + "api_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_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..8d6379610 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,44 @@ 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/PersonProfileApiV2Controller.php b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php new file mode 100644 index 000000000..a5107505c --- /dev/null +++ b/app/plugins/CoreApi/src/Controller/PersonProfileApiV2Controller.php @@ -0,0 +1,546 @@ + + */ + public $entryPointMap = [ + 'index' => 'PersonProfiles', + 'create' => 'PersonProfiles', + 'read' => 'PersonProfiles', + 'update' => 'PersonProfiles', + 'delete' => 'PersonProfiles', + // Aliases / internal dispatch + 'post' => 'PersonProfiles', + 'put' => 'PersonProfiles', + '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 = Utilities::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. + * + * @since COmanage Registry v5.3.0 + * @param EventInterface $event Cake Event + * @return void + */ + 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) + ->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'); + } + + $this->viewBuilder()->addHelpers(['CoreApi.PersonProfile']); + 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 + * + * Uses CakePHP pagination for the general list case. + * + * @since COmanage Registry v5.3.0 + * @param string $coid CO ID + * @return void + */ + public function index(string $coid): void + { + $People = Utilities::getPeopleTable(); + $Identifiers = Utilities::getIdentifiersTable(); + + 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 = Utilities::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)->withType('application/json'); + return; + } + + $pagination = $this->getPaginationParams( + request: $this->request, + defaultLimit: 100, + defaultPage: 1, + defaultDirection: 'ASC' + ); + + // Build base conditions + $conditions = ['People.co_id' => $coId]; + + $statusFilter = $this->request->getQuery('People.status'); + if (is_string($statusFilter) && $statusFilter !== '') { + $conditions['People.status'] = $statusFilter; + } + + // Build query + contain graph + $contain = Utilities::getPersonProfilesTable()->getPersonProfileContain(); + + $baseQuery = $People->find() + ->where($conditions) + ->contain($contain); + + $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', $applied['page']); + $this->set('vv_limit', $applied['limit']); + $this->set('vv_total', $total); + + $this->response = $this->response->withStatus(200)->withType('application/json'); + return; + } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $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'); + $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 + { + $Identifiers = Utilities::getIdentifiersTable(); + + try { + $coId = (int)$coid; + + $typeId = $this->getConfiguredIdentifierTypeId(); + if ($typeId === null) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $personId = $Identifiers->lookupPerson($typeId, $identifier); + $person = Utilities::getPersonProfilesTable()->findPersonWithProfileContain($coId, $personId); + + $this->set('vv_person', $person); + $this->response = $this->response->withStatus(200)->withType('application/json'); + return; + } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $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)->withType('application/json'); + $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. + * + * 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 + * @param string|null $identifier Person identifier (null for create) + * @return void + */ + public function upsert(string $coid, ?string $identifier = null): void + { + $People = Utilities::getPeopleTable(); + $Identifiers = Utilities::getIdentifiersTable(); + + $payload = $this->request->getData(); + if (empty($payload) || !is_array($payload)) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + if (empty($payload['Person']) || !is_array($payload['Person'])) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + // ExternalIdentity: + // - allow on create (POST) + // - ignore on update (PUT) for now + if ($identifier !== null) { + unset($payload['ExternalIdentity']); + } + + $results = []; + $resultCode = 400; + + try { + $coId = (int)$coid; + + $result = $People->getConnection()->transactional(function () use ($payload, $identifier, $coId, $People, $Identifiers): array { + if ($identifier === null) { + $personData = MessageFilter::filterMetadataInbound($payload['Person'], 'Person'); + $personData['co_id'] = $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], + ]; + } + + $typeId = $this->getConfiguredIdentifierTypeId(); + if ($typeId === null) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $personId = $Identifiers->lookupPerson($typeId, $identifier); + + // If the payload includes Person.meta.id, it must match the resolved Person.id. + $payloadPersonId = Utilities::extractPersonIdFromPayload($payload['Person']); + if ($payloadPersonId !== null && $payloadPersonId !== $personId) { + throw new BadRequestException(__d('error', 'invalid.request')); + } + + $contain = Utilities::getPersonProfilesTable()->getPersonProfileContain(); + $person = $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->contain($contain) + ->firstOrFail(); + + $personData = MessageFilter::filterMetadataInbound($payload['Person'], 'Person'); + $personData['co_id'] = $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()]; + } 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 = Utilities::getPeopleTable(); + $Identifiers = Utilities::getIdentifiersTable(); + + $results = []; + $resultCode = 500; + + try { + $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); + + // 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()]; + } 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); + } + + + + /** + * 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; + } +} \ No newline at end of file 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 @@ +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/Lib/Traits/ExternalIdentityCreateTrait.php b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php new file mode 100644 index 000000000..617299629 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Traits/ExternalIdentityCreateTrait.php @@ -0,0 +1,434 @@ + $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 + 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*() using model associations (no hardcoding) + $contain = $this->getExternalIdentityContain($ExternalIdentities); + + $loadedEi = $ExternalIdentities->find() + ->where(['ExternalIdentities.id' => $externalIdentityId]) + ->contain($contain) + ->firstOrFail(); + + foreach ($externalIdentityRaw as $model => $modelPayload) { + // Skip metadata/scalars; only nested association blocks are arrays + if ($model === 'meta' || !is_array($modelPayload)) { + continue; + } + + $incoming = $modelPayload; + $models = Inflector::pluralize((string)$model); + + // Skip unknown/non-owned blocks + if (!$ExternalIdentities->associations()->has($models)) { + continue; + } + + $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; + } + } + + 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' => $models . '.type', + 'targetField' => 'type_id', + 'typesTable' => $Types, + ]; + } + + $this->reconcileExternalIdentityHasMany( + table: $modelsTable, + existing: $loadedEi->{$modelToProperty} ?? [], + incoming: $incoming, + parentFk: 'external_identity_id', + parentId: $externalIdentityId, + coId: $coId, + typeSpec: $typeSpec, + fieldMap: [] + ); + } + } + + /** + * Build the contain graph for ExternalIdentity loads for reconciliation. + * + * Delegates to a shared contain-graph builder so the ExternalIdentities subtree + * stays consistent between Person Profile reads and EI reconciliation loads. + * + * @param Table $externalIdentitiesTable + * @return array + * @since COmanage Registry v5.3.0 + */ + protected function getExternalIdentityContain(Table $externalIdentitiesTable): array + { + return ContainGraphBuilder::buildExternalIdentityContain($externalIdentitiesTable); + } + + /** + * 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 new file mode 100644 index 000000000..c14010b78 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Traits/PersonProfileUpsertTrait.php @@ -0,0 +1,456 @@ + '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). + * - 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. + * - 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 +{ + /** + * 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 = Utilities::getPeopleTable(); + + /** @var \App\Model\Table\TypesTable $Types */ + $Types = Utilities::getTypesTable(); + + if ($loadedPerson === null) { + $contain = Utilities::getPersonProfilesTable()->getPersonProfileContain(); + + $loadedPerson = $People->find() + ->where(['People.id' => $personId, 'People.co_id' => $coId]) + ->contain($contain) + ->firstOrFail(); + } + + foreach ($payload as $model => $modelPayload) { + if ($model === 'Person' || $model === 'ExternalIdentity') { + continue; + } + + $incoming = is_array($modelPayload) ? $modelPayload : []; + $models = Inflector::pluralize((string)$model); + + // Skip unknown/non-owned top-level blocks (eg embedded Group in GroupMember payload) + if (!$People->associations()->has($models)) { + continue; + } + + // 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; + } + } + + foreach ($incoming as $idx => $incomingRoleRaw) { + if (!is_array($incomingRoleRaw)) { + continue; + } + + $incomingRole = Utilities::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; + + // 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; + } + + $childAssoc = Inflector::pluralize((string)$childKey); + + if (!$PersonRoles->associations()->has($childAssoc)) { + continue; + } + + $incomingChildren = $childPayload; + + $childTable = $PersonRoles->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->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: [] + ); + } + } + } + + /** + * 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 = Utilities::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 = 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 { + // 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); + + 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; + } +} diff --git a/app/plugins/CoreApi/src/Lib/Utils/ContainGraphBuilder.php b/app/plugins/CoreApi/src/Lib/Utils/ContainGraphBuilder.php new file mode 100644 index 000000000..ebaa22139 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Utils/ContainGraphBuilder.php @@ -0,0 +1,132 @@ + + */ + 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/Lib/Utils/MessageFilter.php b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php new file mode 100644 index 000000000..eedad64c8 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Utils/MessageFilter.php @@ -0,0 +1,204 @@ + 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 + * @return array + * @since COmanage Registry v5.3.0 + */ + public static function filterMetadataInbound(array $record, string $modelName, array $extraSkipFields = []): array + { + $ret = []; + + // Changelog FK for this model (eg ExternalIdentity => external_identity_id) + $changelogFk = Inflector::underscore($modelName) . '_id'; + $sourceFk = 'source_' . (Inflector::underscore($modelName) . '_id'); + + + $skip = array_merge( + [ + // System/changelog metadata + 'actor_identifier', + 'created', + 'deleted', + 'id', + 'modified', + 'revision', + + // 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', + + // Prevent client control of changelog chain pointer for this model + $changelogFk, + $sourceFk, + ], + $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). + * + * 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 + * @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 = []; + + // 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( + [ + // System/changelog metadata + 'actor_identifier', + 'created', + 'deleted', + 'id', + 'modified', + 'revision', + 'lft', + 'rght', + + // 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 ($changelogFk !== null) { + $metaFields[] = $changelogFk; + } + if ($sourceFk !== null) { + $metaFields[] = $sourceFk; + } + + 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; + } + + if (in_array((string)$k, $metaFields, true)) { + $newa['meta'][(string)$k] = $v; + continue; + } + + // Everything else (including ownership FKs like person_id) remains first-class + $newa[(string)$k] = $v; + } + + $ret[$m] = $newa; + } + + return $ret; + } +} 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..b8c99bba5 --- /dev/null +++ b/app/plugins/CoreApi/src/Lib/Utils/Utilities.php @@ -0,0 +1,154 @@ + $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; + } + + /** + * 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. + * + * @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 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; + } +} diff --git a/app/plugins/CoreApi/src/Model/Entity/PersonProfile.php b/app/plugins/CoreApi/src/Model/Entity/PersonProfile.php new file mode 100644 index 000000000..6acac19eb --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Entity/PersonProfile.php @@ -0,0 +1,59 @@ + + */ + 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..ea7a82e07 --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Table/PersonProfilesTable.php @@ -0,0 +1,257 @@ + + * @since COmanage Registry v5.3.0 + */ + protected const PERSON_PROFILE_CONTAIN_BLACKLIST = [ + 'ActorNotifications', + 'AuthenticatorStatuses', + 'PrimaryName', + 'HistoryRecords', + 'ExtIdentitySourceRecords', + 'JobHistoryRecords', + 'ResolverNotifications', + 'ApplicationStates', + 'Petitions', + 'ProvisioningHistoryRecords', + 'SubjectNotifications', + ]; + + /** + * Perform Cake Model initialization. + * + * @param array $config Configuration options passed to constructor + * @return void + * @since COmanage Registry v5.3.0 + */ + public function initialize(array $config): void + { + parent::initialize($config); + + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Apis'); + $this->belongsTo('IdentifierTypes') + ->setClassName('Types') + ->setForeignKey('identifier_type_id') + ->setProperty('identifier_type'); + + $this->setDisplayField('api_id'); + + // This is an Entry Point Model under the Api (pluggable) config + $this->setPrimaryLink('api_id'); + $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 + $this->setAutoViewVars([ + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'indexResponseTypes' => [ + 'type' => 'enum', + 'class' => 'CoreApi.ResponseTypesEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Find a Person record scoped to CO, including all associations required to build + * a Person Profile message. + * + * @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 + { + /** @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. + * + * 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 CakePHP contain graph suitable for Query::contain() + * @since COmanage Registry v5.3.0 + */ + public function getPersonProfileContain(): array + { + /** @var \App\Model\Table\PeopleTable $People */ + $People = TableRegistry::getTableLocator()->get('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 = ContainGraphBuilder::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(); + + $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 = ContainGraphBuilder::buildExternalIdentityContain($externalIdentitiesTarget); + + $contain = ContainGraphBuilder::replaceContainEntry($contain, 'ExternalIdentities', $externalIdentitiesContain); + } + + return $contain; + } + + /** + * Table specific logic to generate a display field. + * + * @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 + { + return $entity->api->description; + } + + /** + * Set validation rules. + * + * @param Validator $validator Validator + * @return Validator Validator + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('api_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_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/src/View/Helper/PersonProfileHelper.php b/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php new file mode 100644 index 000000000..255801483 --- /dev/null +++ b/app/plugins/CoreApi/src/View/Helper/PersonProfileHelper.php @@ -0,0 +1,343 @@ + + * + * @since COmanage v5.3.0 + */ + 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. + * + * @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 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 $person + * @return array + * + * @since COmanage v5.3.0 + */ + public function formatPersonProfile(object $person): array + { + $raw = $this->splitRootRecord($person, 'Person'); + + $out = []; + foreach ($raw as $modelName => $record) { + $filtered = MessageFilter::filterMetadataOutbound([$modelName => $record], $modelName); + $out[$modelName] = $filtered[$modelName] ?? []; + } + + return $this->addDerivedFieldsRecursive($out); + } + + /** + * Split the root Person entity into the v2-ish response shape: + * [ + * 'Person' => [columns...], + * 'EmailAddress' => [[...], ...], + * 'Identifier' => [[...], ...], + * ... + * ] + * + * Associations are detected dynamically based on arrays returned by toArray(). + * + * IMPORTANT: + * normalizeEntityToArray() already converts association property names + * (eg email_addresses) into model keys (eg EmailAddress). Do not inflect again here. + * + * @param object $entity + * @param string $rootModelName + * @return array + */ + protected function splitRootRecord(object $entity, string $rootModelName): array + { + $arr = $this->normalizeEntityToArray($entity, $rootModelName); + + $root = []; + $associations = []; + + foreach ($arr as $k => $v) { + if (is_array($v)) { + $associations[$k] = $v; + continue; + } + + $root[$k] = $v; + } + + $out = [ + $rootModelName => $root, + ]; + + foreach ($associations as $modelKey => $value) { + $out[(string)$modelKey] = $value; + } + + return $out; + } + + /** + * 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 + * + * @param object $entity + * @param string $modelName + * @return array + */ + protected function normalizeEntityToArray(object $entity, string $modelName): array + { + $arr = method_exists($entity, 'toArray') ? (array)$entity->toArray() : (array)$entity; + + return $this->normalizeRecordArray($arr, $modelName); + } + + /** + * Normalize a record array recursively, renaming association keys to model keys. + * Scalar keys are restricted to the model's schema columns. + * + * @param array $record + * @param string $modelName + * @return array + */ + protected function normalizeRecordArray(array $record, string $modelName): array + { + $out = []; + + $schemaColumns = $this->getSchemaColumnsForModelKey($modelName); + $schemaColumnSet = $schemaColumns !== null ? array_fill_keys($schemaColumns, true) : null; + + foreach ($record as $k => $v) { + $key = (string)$k; + + if ($v instanceof \BackedEnum) { + if ($schemaColumnSet === null || isset($schemaColumnSet[$key])) { + $out[$key] = $v->value; + } + continue; + } + + 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; + } + + if ($schemaColumnSet === null || isset($schemaColumnSet[$key])) { + $out[$key] = $v; + } + } + + return $out; + } + + /** + * Get schema columns for a given API model key (eg "PersonRole" or "EmailAddress"). + * + * Returns null if the table cannot be resolved (fallback: do not enforce schema). + * + * @param string $modelKey + * @return array|null + */ + protected function getSchemaColumnsForModelKey(string $modelKey): ?array + { + if (isset($this->schemaColumnsCache[$modelKey])) { + return $this->schemaColumnsCache[$modelKey]; + } + + $tableAlias = $this->modelKeyToTableAlias($modelKey); + if ($tableAlias === null) { + return null; + } + + $cacheKey = 'table:' . $tableAlias; + if (isset($this->schemaColumnsCache[$cacheKey])) { + $this->schemaColumnsCache[$modelKey] = $this->schemaColumnsCache[$cacheKey]; + return $this->schemaColumnsCache[$modelKey]; + } + + try { + $table = TableRegistry::getTableLocator()->get($tableAlias); + $columns = $table->getSchema()->columns(); + } catch (\Throwable $e) { + return null; + } + + $this->schemaColumnsCache[$cacheKey] = $columns; + $this->schemaColumnsCache[$modelKey] = $columns; + + return $columns; + } + + /** + * Map API model keys to Cake table aliases. + * + * @param string $modelKey + * @return string|null + */ + protected function modelKeyToTableAlias(string $modelKey): ?string + { + if ($modelKey === 'Person') { + return 'People'; + } + + return Inflector::pluralize($modelKey); + } + + /** + * Add derived fields recursively. + * Current behavior: + * - if "type_id" is present and no "type" exists, add "type" label (keeps type_id too) + * + * @param mixed $node + * @return mixed + */ + protected function addDerivedFieldsRecursive(mixed $node): mixed + { + if (!is_array($node)) { + return $node; + } + + if (!$this->isAssoc($node)) { + foreach ($node as $i => $item) { + $node[$i] = $this->addDerivedFieldsRecursive($item); + } + return $node; + } + + foreach ($node as $k => $v) { + $node[$k] = $this->addDerivedFieldsRecursive($v); + } + + 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); + + $node['type'] = $this->typeLabel($typeId); + } + + // 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 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 $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 new file mode 100644 index 000000000..5c6314997 --- /dev/null +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile.php @@ -0,0 +1,84 @@ +PersonProfile->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); + 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 = $this->PersonProfile->formatPersonProfile($person); + +$this->set('vv_results', $out); +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..31332b768 --- /dev/null +++ b/app/plugins/CoreApi/templates/PersonProfileApiV2/json/person_profile_slim.php @@ -0,0 +1,40 @@ +identifiers)) { + foreach ($person->identifiers as $id) { + if($vv_person_profile_cfg->identifier_type_id == $id->type_id) { + $items[$person->id] = $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 $id => $identifier) { + $out[$id] = $identifier; + } + + $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 new file mode 100644 index 000000000..92cdb1200 --- /dev/null +++ b/app/plugins/CoreApi/templates/PersonProfiles/fields.inc @@ -0,0 +1,50 @@ + 'information', + 'message' => __d('core_api', 'information.endpoint.person.profile', [$vv_api_endpoint]) + ] + ]; +} + +// Fields for person_profiles table +$fields = [ + '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 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. *