[Bps-public-commit] rt-extension-rest2 branch, rest2_binary_cf_value_improvements, created. 1.07-9-gdb00b3a

Aaron Trevena ast at bestpractical.com
Thu Apr 2 12:06:20 EDT 2020


The branch, rest2_binary_cf_value_improvements has been created
        at  db00b3a6395e56b28e8a94823ffa7ec680f2c115 (commit)

- Log -----------------------------------------------------------------
commit 3214744c9934dc00620bedfde55708c40fb22102
Author: gibus <gibus at easter-eggs.com>
Date:   Mon Oct 22 22:35:51 2018 +0200

    Add entry point to download image or binary ObjectCustomFieldValue
    
    Add documentation for downloading image or binary ObjectCustomFieldValue
    
    Based on publuc github PR #23

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index c4b61c6..7d1c7e9 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -356,6 +356,11 @@ Below are some examples using the endpoints above.
     GET /attachment/:id
         retrieve an attachment
 
+=head3 Image and Binary Object Custom Field Values
+
+    GET /download/cf/:id
+        retrieve an image or a binary file as an object custom field value
+
 =head3 Queues
 
     GET /queues/all
diff --git a/lib/RT/Extension/REST2/Resource/ObjectCustomFieldValue.pm b/lib/RT/Extension/REST2/Resource/ObjectCustomFieldValue.pm
new file mode 100644
index 0000000..80c14de
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/ObjectCustomFieldValue.pm
@@ -0,0 +1,59 @@
+package RT::Extension::REST2::Resource::ObjectCustomFieldValue;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+use RT::Extension::REST2::Util qw( error_as_json );
+
+extends 'RT::Extension::REST2::Resource::Record';
+with 'RT::Extension::REST2::Resource::Record::WithETag';
+
+sub dispatch_rules {
+    Path::Dispatcher::Rule::Regex->new(
+        regex => qr{^/download/cf/(\d+)/?$},
+        block => sub { { record_class => 'RT::ObjectCustomFieldValue', record_id => shift->pos(1) } },
+    )
+}
+
+sub allowed_methods { ['GET', 'HEAD'] }
+
+sub content_types_provided {
+    my $self = shift;
+    { [ {$self->record->ContentType || 'text/plain; charset=utf-8' => 'to_binary'} ] };
+}
+
+sub forbidden {
+    my $self = shift;
+    return 0 unless $self->record->id;
+    return !$self->record->CurrentUserHasRight('SeeCustomField');
+}
+
+sub to_binary {
+    my $self = shift;
+    unless ($self->record->CustomFieldObj->Type =~ /^(?:Image|Binary)$/) {
+        return error_as_json(
+            $self->response,
+            \400, "Only Image and Binary CustomFields can be downloaded");
+    }
+
+    my $content_type = $self->record->ContentType || 'text/plain; charset=utf-8';
+    if (RT->Config->Get('AlwaysDownloadAttachments')) {
+        $self->response->headers_out->{'Content-Disposition'} = "attachment";
+    }
+    elsif (!RT->Config->Get('TrustHTMLAttachments')) {
+        $content_type = 'text/plain; charset=utf-8' if ($content_type =~ /^text\/html/i);
+    }
+
+    $self->response->content_type($content_type);
+
+    my $content = $self->record->LargeContent;
+    $self->response->content_length(length $content);
+    $self->response->body($content);
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
+

commit c6e64011da54b8215bea4dc4852ca9159a72caab
Author: gibus <gibus at easter-eggs.com>
Date:   Mon Oct 22 22:38:05 2018 +0200

    Add tests for downloading image or binary ObjectCustomFieldValue
    
    Based on public github PR#23

diff --git a/xt/cf-image.t b/xt/cf-image.t
new file mode 100644
index 0000000..3f55fe6
--- /dev/null
+++ b/xt/cf-image.t
@@ -0,0 +1,72 @@
+use strict;
+use warnings;
+use lib 't/lib';
+use RT::Extension::REST2::Test tests => undef;
+use Test::Deep;
+
+my $mech = RT::Extension::REST2::Test->mech;
+
+my $auth = RT::Extension::REST2::Test->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Extension::REST2::Test->user;
+
+my $image_name = 'image.png';
+my $image_path = RT::Test::get_relocatable_file($image_name, 'data');
+my $image_content;
+open my $fh, '<', $image_path or die "Cannot read $image_path: $!\n";
+{
+    local $/;
+    $image_content = <$fh>;
+}
+close $fh;
+
+my $image_cf = RT::CustomField->new(RT->SystemUser);
+$image_cf->Create(LookupType => 'RT::Queue-RT::Ticket', Name => 'Image CF', Type => 'Image', MaxValues => 1, Queue => 'General');
+my $freeform_cf = RT::CustomField->new(RT->SystemUser);
+$freeform_cf->Create(LookupType => 'RT::Queue-RT::Ticket', Name => 'Text CF', Type => 'Freeform', MaxValues => 1, Queue => 'General');
+
+my $ticket = RT::Ticket->new(RT->SystemUser);
+$ticket->Create(Queue => 'General', Subject => 'Test ticket with image cf', "CustomField-" . $freeform_cf->id => 'hello world');
+$ticket->AddCustomFieldValue(Field => $image_cf->id, Value => 'image.png', ContentType => 'image/png', LargeContent => $image_content);
+
+my $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+my $text_ocfv = $ticket->CustomFieldValues('Text CF')->First;
+
+# Rights Test - No SeeCustomField
+{
+    my $res = $mech->get("$rest_base_path/download/cf/" . $image_ocfv->id,
+        'Authorization' => $auth,
+    );
+    is($res->code, 403);
+}
+
+$user->PrincipalObj->GrantRight( Right => 'SeeCustomField' );
+
+# Try undef ObjectCustomFieldValue
+{
+    my $res = $mech->get("$rest_base_path/download/cf/666",
+        'Authorization' => $auth,
+    );
+    is($res->code, 404);
+}
+
+# Download cf text
+{
+    my $res = $mech->get("$rest_base_path/download/cf/" . $text_ocfv->id,
+        'Authorization' => $auth,
+    );
+    is($res->code, 400);
+    is($mech->json_response->{message}, 'Only Image and Binary CustomFields can be downloaded');
+}
+
+# Download cf image
+{
+    $user->PrincipalObj->GrantRight( Right => 'SeeCustomField' );
+    my $res = $mech->get("$rest_base_path/download/cf/" . $image_ocfv->id,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is($res->content, $image_content);
+}
+
+done_testing;
diff --git a/xt/data/image.png b/xt/data/image.png
new file mode 100644
index 0000000..8a87374
Binary files /dev/null and b/xt/data/image.png differ

commit 1119796e88af0da6cbf252cb7c0a65d972ca4d8a
Author: gibus <gibus at easter-eggs.com>
Date:   Tue Oct 23 20:05:12 2018 +0200

    Allow Image or Binary ObjectCustomFieldValue to be upload as JSON Base64 encoded content when creating or updating resource
    
    Add documentation for Image or Binary ObjectCustomFieldValue to be upload as JSON Base64 encoded content when creating or updating resource
    
    Based on public gitub PR #24

diff --git a/README b/README
index 40138e3..e18120d 100644
--- a/README
+++ b/README
@@ -559,6 +559,118 @@ USAGE
     Each item is nearly the same representation used when an individual
     resource is requested.
 
+  Object Custom Field Values
+    When creating (via POST) or updating (via PUT) a ressource which has
+    some custom fields attached to, you can specify the value(s) for these
+    customfields in the CustomFields property of the JSON object parameter.
+    The CustomFields property should be a JSON object, with each property
+    being the custom field identifier or name. If the custom field can have
+    only one value, you just have to speciy the value as JSON string for
+    this custom field. If the customfield can have several value, you have
+    to specify a JSON array of each value you want for this custom field.
+
+        "CustomFields": {
+            "XX_SINGLE_CF_ID_XX"   : "My Single Value",
+            "XX_MULTI_VALUE_CF_ID": [
+                "My First Value",
+                "My Second Value"
+            ]
+        }
+
+    Note that for a multi-value custom field, you have to specify all the
+    values for this custom field. Therefore if the customfield for this
+    ressource already has some values, the existing values must be including
+    in your update request if you want to keep them (and add some new
+    values). Conversely, if you want to delete some existing values, do not
+    include them in your update request (including only values you wan to
+    keep). The following example deletes "My Second Value" from the previous
+    example:
+
+        "CustomFields": {
+            "XX_MULTI_VALUE_CF_ID": [
+                "My First Value"
+            ]
+        }
+
+    New values for Image and Binary custom fields can be set by specifying a
+    JSON object as value for the custom field identifier or name with the
+    following properties:
+
+    FileName
+        The name of the file to attach to your response/comment, mandatory.
+
+    FileType
+        The MIME type of the file to attach to your response/comment,
+        mandatory.
+
+    FileContent
+        The content, *encoded in MIME Base64* of the file to attach to your
+        response/comment, mandatory.
+
+    The reason why you should encode the content of the image or binary file
+    to MIME Base64 is that a JSON string value should be a sequence of zero
+    or more Unicode characters. MIME Base64 is a binary-to-text encoding
+    scheme widely used (for eg. by web browser) to send binary data when
+    text data is required. Most popular language have MIME Base64 libraries
+    that you can use to encode the content of your attached files (see
+    MIME::Base64 for Perl). Note that even text files should be MIME Base64
+    encoded to be passed in the FileContent property.
+
+        "CustomFields": {
+            "XX_SINGLE_IMAGE_OR_BINARY_CF_ID_XX"   : {
+                "FileName"   : "image.png",
+                "FileType"   : "image/png",
+                "FileContent": "XX_BASE_64_STRING_XX"
+            },
+            "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID": [
+                {
+                    "FileName"   : "another_image.png",
+                    "FileType"   : "image/png",
+                    "FileContent": "XX_BASE_64_STRING_XX"
+                },
+                {
+                    "FileName"   : "hello_world.txt",
+                    "FileType"   : "text/plain",
+                    "FileContent": "SGVsbG8gV29ybGQh"
+                }
+            ]
+        }
+
+    If you want to delete some existing values from a multi-value image or
+    binary custom field, you can just pass the existing filename as value
+    for the custom field identifier or name, no need to upload again the
+    content of the file. The following example will delete the text file and
+    keep the image upload in previous example:
+
+        "CustomFields": {
+            "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID": [
+                    "image.png"
+            ]
+        }
+
+    To download an image or binary file which is the custom field value of a
+    resource, you just have to make a GET request to the entry point
+    returned for the corresponding custom field when fetching this resource,
+    and it will return the content of the file as an octet string:
+
+        curl -i -H 'Authorization: token XX_TOKEN_XX' 'XX_TICKET_URL_XX'
+
+        {
+            […]
+            "XX_IMAGE_OR_BINARY_CF_ID_XX" : [
+                {
+                    "content_type" : "image/png",
+                    "filename" : "image.png",
+                    "_url" : "XX_RT_URL_XX/REST/2.0/download/cf/XX_IMAGE_OR_BINARY_OCFV_ID_XX"
+                }
+            ],
+            […]
+        },
+
+        curl -i -H 'Authorization: token XX_TOKEN_XX'
+            'XX_RT_URL_XX/REST/2.0/download/cf/XX_IMAGE_OR_BINARY_OCFV_ID_XX'
+            > file.png
+
   Paging
     All plural resources (such as /tickets) require pagination, controlled
     by the query parameters page and per_page. The default page size is 20
diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 7d1c7e9..b791740 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -616,6 +616,94 @@ standard JSON format:
 Each item is nearly the same representation used when an individual resource
 is requested.
 
+=head2 Object Custom Field Values
+
+When creating (via C<POST>) or updating (via C<PUT>) a ressource which has some custom fields attached to, you can specify the value(s) for these customfields in the C<CustomFields> property of the JSON object parameter. The C<CustomFields> property should be a JSON object, with each property being the custom field identifier or name. If the custom field can have only one value, you just have to speciy the value as JSON string for this custom field. If the customfield can have several value, you have to specify a JSON array of each value you want for this custom field.
+
+    "CustomFields": {
+        "XX_SINGLE_CF_ID_XX"   : "My Single Value",
+        "XX_MULTI_VALUE_CF_ID": [
+            "My First Value",
+            "My Second Value"
+        ]
+    }
+
+Note that for a multi-value custom field, you have to specify all the values for this custom field. Therefore if the customfield for this ressource already has some values, the existing values must be including in your update request if you want to keep them (and add some new values). Conversely, if you want to delete some existing values, do not include them in your update request (including only values you wan to keep). The following example deletes "My Second Value" from the previous example:
+
+    "CustomFields": {
+        "XX_MULTI_VALUE_CF_ID": [
+            "My First Value"
+        ]
+    }
+
+New values for Image and Binary custom fields can be set by specifying a JSON object as value for the custom field identifier or name with the following properties:
+
+=over 4
+
+=item C<FileName>
+
+The name of the file to attach to your response/comment, mandatory.
+
+=item C<FileType>
+
+The MIME type of the file to attach to your response/comment, mandatory.
+
+=item C<FileContent>
+
+The content, I<encoded in C<MIME Base64>> of the file to attach to your response/comment, mandatory.
+
+=back
+
+The reason why you should encode the content of the image or binary file to C<MIME Base64> is that a JSON string value should be a sequence of zero or more Unicode characters. C<MIME Base64> is a binary-to-text encoding scheme widely used (for eg. by web browser) to send binary data when text data is required. Most popular language have C<MIME Base64> libraries that you can use to encode the content of your attached files (see L<MIME::Base64> for C<Perl>). Note that even text files should be C<MIME Base64> encoded to be passed in the C<FileContent> property.
+
+    "CustomFields": {
+        "XX_SINGLE_IMAGE_OR_BINARY_CF_ID_XX"   : {
+            "FileName"   : "image.png",
+            "FileType"   : "image/png",
+            "FileContent": "XX_BASE_64_STRING_XX"
+        },
+        "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID": [
+            {
+                "FileName"   : "another_image.png",
+                "FileType"   : "image/png",
+                "FileContent": "XX_BASE_64_STRING_XX"
+            },
+            {
+                "FileName"   : "hello_world.txt",
+                "FileType"   : "text/plain",
+                "FileContent": "SGVsbG8gV29ybGQh"
+            }
+        ]
+    }
+
+If you want to delete some existing values from a multi-value image or binary custom field, you can just pass the existing filename as value for the custom field identifier or name, no need to upload again the content of the file. The following example will delete the text file and keep the image upload in previous example:
+
+    "CustomFields": {
+        "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID": [
+                "image.png"
+        ]
+    }
+
+To download an image or binary file which is the custom field value of a resource, you just have to make a C<GET> request to the entry point returned for the corresponding custom field when fetching this resource, and it will return the content of the file as an octet string:
+
+    curl -i -H 'Authorization: token XX_TOKEN_XX' 'XX_TICKET_URL_XX'
+
+    {
+        […]
+        "XX_IMAGE_OR_BINARY_CF_ID_XX" : [
+            {
+                "content_type" : "image/png",
+                "filename" : "image.png",
+                "_url" : "XX_RT_URL_XX/REST/2.0/download/cf/XX_IMAGE_OR_BINARY_OCFV_ID_XX"
+            }
+        ],
+        […]
+    },
+
+    curl -i -H 'Authorization: token XX_TOKEN_XX'
+        'XX_RT_URL_XX/REST/2.0/download/cf/XX_IMAGE_OR_BINARY_OCFV_ID_XX'
+        > file.png
+
 =head2 Paging
 
 All plural resources (such as C</tickets>) require pagination, controlled by
diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index 6d04d3b..2044872 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -76,9 +76,25 @@ sub _update_custom_fields {
         next unless $cf->ObjectTypeFromLookupType($cf->__Value('LookupType'))->isa(ref $record);
 
         if ($cf->SingleValue) {
+            my %args;
             if (ref($val) eq 'ARRAY') {
                 $val = $val->[0];
             }
+            elsif (ref($val) eq 'HASH' && $cf->Type =~ /^(?:Image|Binary)$/) {
+                my @required_fields;
+                foreach my $field ('FileName', 'FileType', 'FileContent') {
+                    unless ($val->{$field}) {
+                        push @required_fields, "$field is a required field for Image/Binary ObjectCustomFieldValue";
+                    }
+                }
+                if (@required_fields) {
+                    push @results, @required_fields;
+                    next;
+                }
+                $args{ContentType} = delete $val->{FileType};
+                $args{LargeContent} = MIME::Base64::decode_base64(delete $val->{FileContent});
+                $val = delete $val->{FileName};
+            }
             elsif (ref($val)) {
                 die "Invalid value type for CustomField $cfid";
             }
@@ -86,15 +102,41 @@ sub _update_custom_fields {
             my ($ok, $msg) = $record->AddCustomFieldValue(
                 Field => $cf,
                 Value => $val,
+                %args,
             );
             push @results, $msg;
         }
         else {
             my %count;
             my @vals = ref($val) eq 'ARRAY' ? @$val : $val;
-            for (@vals) {
-                $count{$_}++;
+            my @content_vals;
+            my %args;
+            for my $value (@vals) {
+                if (ref($value) eq 'HASH' && $cf->Type =~ /^(?:Image|Binary)$/) {
+                    my @required_fields;
+                    foreach my $field ('FileName', 'FileType', 'FileContent') {
+                        unless ($value->{$field}) {
+                            push @required_fields, "$field is a required field for Image/Binary ObjectCustomFieldValue";
+                        }
+                    }
+                    if (@required_fields) {
+                        push @results, @required_fields;
+                        next;
+                    }
+                    my $key = delete $value->{FileName};
+                    $args{$key}->{ContentType} = delete $value->{FileType};
+                    $args{$key}->{LargeContent} = MIME::Base64::decode_base64(delete $value->{FileContent});
+                    $count{$key}++;
+                    push @content_vals, $key;
+                }
+                elsif (ref($value)) {
+                    die "Invalid value type for CustomField $cfid";
+                }
+                else {
+                    $count{$value}++;
+                }
             }
+            @vals = @content_vals if @content_vals;
 
             my $ocfvs = $cf->ValuesForObject( $record );
             my %ocfv_id;
@@ -118,6 +160,7 @@ sub _update_custom_fields {
                         my ($ok, $msg) = $record->AddCustomFieldValue(
                             Field => $cf,
                             Value => $key,
+                            $args{$key} ? %{$args{$key}} : (),
                         );
                         push @results, $msg;
                     }
@@ -297,6 +340,31 @@ sub create_record {
         if ($cfs) {
             while (my ($id, $value) = each(%$cfs)) {
                 delete $cfs->{$id};
+                if (ref($value) eq 'HASH') {
+                    foreach my $field ('FileName', 'FileType', 'FileContent') {
+                        return (0, 0, "$field is a required field for Image/Binary ObjectCustomFieldValue")
+                            unless $value->{$field};
+                    }
+                    $value->{Value} = delete $value->{FileName};
+                    $value->{ContentType} = delete $value->{FileType};
+                    $value->{LargeContent} = MIME::Base64::decode_base64(delete $value->{FileContent});
+                }
+                elsif (ref($value) eq 'ARRAY') {
+                    my $i = 0;
+                    foreach my $single_value (@$value) {
+                        if (ref($single_value) eq 'HASH') {
+                            foreach my $field ('FileName', 'FileType', 'FileContent') {
+                                return (0, 0, "$field is a required field for Image/Binary ObjectCustomFieldValue")
+                                    unless $single_value->{$field};
+                            }
+                            $single_value->{Value} = delete $single_value->{FileName};
+                            $single_value->{ContentType} = delete $single_value->{FileType};
+                            $single_value->{LargeContent} = MIME::Base64::decode_base64(delete $single_value->{FileContent});
+                            $value->[$i] = $single_value;
+                        }
+                        $i++;
+                    }
+                }
                 $args{"CustomField-$id"} = $value;
             }
         }

commit 30d13a8548582274652059ebf693dafef8ada17c
Author: gibus <gibus at easter-eggs.com>
Date:   Tue Oct 23 20:05:36 2018 +0200

    Add tests for binary ObjectCustomFieldValue upload as base64
    
    Add tests for Image or Binary ObjectCustomFieldValue to be upload as JSON Base64 encoded content when creating or updating resource
    
    Based on public gitub PR #24

diff --git a/xt/ticket-customfields.t b/xt/ticket-customfields.t
index 5e8174f..f48d9f1 100644
--- a/xt/ticket-customfields.t
+++ b/xt/ticket-customfields.t
@@ -384,5 +384,286 @@ for my $value (
     }
 }
 
+# Ticket Creation with image CF
+my $image_name = 'image.png';
+my $image_path = RT::Test::get_relocatable_file($image_name, 'data');
+my $image_content;
+open my $fh, '<', $image_path or die "Cannot read $image_path: $!\n";
+{
+    local $/;
+    $image_content = <$fh>;
+}
+close $fh;
+my $image_cf = RT::CustomField->new(RT->SystemUser);
+$image_cf->Create(LookupType => 'RT::Queue-RT::Ticket', Name => 'Image CF', Type => 'Image', MaxValues => 1, Queue => 'General');
+my $image_cf_id = $image_cf->id;
+{
+    my $payload = {
+        Subject => 'Ticket creation with image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket creation with Base64 encoded Image Custom Field using REST API.',
+        CustomFields => {
+            $image_cf_id => {
+                FileName => $image_name,
+                FileType => 'image/png',
+                FileContent => MIME::Base64::encode_base64($image_content),
+            },
+        },
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+    is($image_ocfv->Content, $image_name);
+    is($image_ocfv->ContentType, 'image/png');
+    is($image_ocfv->LargeContent, $image_content);
+}
+
+# Ticket Update with image CF
+{
+    # Ticket Creation with empty image CF
+    my $payload = {
+        Subject => 'Ticket creation with empty image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket update with Base64 encoded Image Custom Field using REST API.',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+    is($image_ocfv, undef);
+
+    # Ticket update with a value for image CF
+    $payload = {
+        Subject => 'Ticket with image CF',
+        CustomFields => {
+            $image_cf_id => {
+                FileName => $image_name,
+                FileType => 'image/png',
+                FileContent => MIME::Base64::encode_base64($image_content),
+            },
+        },
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket creation with empty image CF' to 'Ticket with image CF'", "Image CF $image_name added"]);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+    is($image_ocfv->Content, $image_name);
+    is($image_ocfv->ContentType, 'image/png');
+    is($image_ocfv->LargeContent, $image_content);
+}
+
+# Ticket Creation with multi-value image CF
+my $multi_image_cf = RT::CustomField->new(RT->SystemUser);
+$multi_image_cf->Create(LookupType => 'RT::Queue-RT::Ticket', Name => 'Multi Image CF', Type => 'Image', MaxValues => 0, Queue => 'General');
+my $multi_image_cf_id = $multi_image_cf->id;
+{
+    my $payload = {
+        Subject => 'Ticket creation with multi-value image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket creation with Base64 encoded Multi-Value Image Custom Field using REST API.',
+        CustomFields => {
+            $multi_image_cf_id => [
+                {
+                    FileName => $image_name,
+                    FileType => 'image/png',
+                    FileContent => MIME::Base64::encode_base64($image_content),
+                },
+                {
+                    FileName => 'Duplicate',
+                    FileType => 'image/png',
+                    FileContent => MIME::Base64::encode_base64($image_content),
+                },
+            ],
+        },
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+    is(scalar(@multi_image_ocfvs), 2);
+    is($multi_image_ocfvs[0]->Content, $image_name);
+    is($multi_image_ocfvs[0]->ContentType, 'image/png');
+    is($multi_image_ocfvs[0]->LargeContent, $image_content);
+    is($multi_image_ocfvs[1]->Content, 'Duplicate');
+    is($multi_image_ocfvs[1]->ContentType, 'image/png');
+    is($multi_image_ocfvs[1]->LargeContent, $image_content);
+}
+
+# Ticket Update with multi-value image CF
+{
+    # Ticket Creation with empty multi-value image CF
+    my $payload = {
+        Subject => 'Ticket creation with empty multi-value image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket creation with Base64 encoded Multi-Value Image Custom Field using REST API.',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my $multi_image_ocfvs = $ticket->CustomFieldValues('Multi Image CF');
+    is($multi_image_ocfvs->Count, 0);
+
+    # Ticket update with two values for multi-value image CF
+    $payload = {
+        Subject => 'Ticket with multi-value image CF',
+        CustomFields => {
+            $multi_image_cf_id => [
+                {
+                    FileName => $image_name,
+                    FileType => 'image/png',
+                    FileContent => MIME::Base64::encode_base64($image_content),
+                },
+                {
+                    FileName => 'Duplicate',
+                    FileType => 'image/png',
+                    FileContent => MIME::Base64::encode_base64($image_content),
+                },
+            ],
+        },
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket creation with empty multi-value image CF' to 'Ticket with multi-value image CF'", "$image_name added as a value for Multi Image CF", "Duplicate added as a value for Multi Image CF"]);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+    is(scalar(@multi_image_ocfvs), 2);
+    is($multi_image_ocfvs[0]->Content, $image_name);
+    is($multi_image_ocfvs[0]->ContentType, 'image/png');
+    is($multi_image_ocfvs[0]->LargeContent, $image_content);
+    is($multi_image_ocfvs[1]->Content, 'Duplicate');
+    is($multi_image_ocfvs[1]->ContentType, 'image/png');
+    is($multi_image_ocfvs[1]->LargeContent, $image_content);
+
+    # Ticket update with deletion of one value for multi-value image CF
+    $payload = {
+        Subject => 'Ticket with deletion of one value for multi-value image CF',
+        CustomFields => {
+            $multi_image_cf_id => [ $image_name ],
+        },
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket with multi-value image CF' to 'Ticket with deletion of one value for multi-value image CF'", "Duplicate is no longer a value for custom field Multi Image CF"]);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+    is(scalar(@multi_image_ocfvs), 1);
+    is($multi_image_ocfvs[0]->Content, $image_name);
+    is($multi_image_ocfvs[0]->ContentType, 'image/png');
+    is($multi_image_ocfvs[0]->LargeContent, $image_content);
+
+    # Ticket update with non-unique values for multi-value image CF
+    $payload = {
+        Subject => 'Ticket with non-unique values for multi-value image CF',
+        CustomFields => {
+            $multi_image_cf_id => [
+                {
+                    FileName => $image_name,
+                    FileType => 'image/png',
+                    FileContent => MIME::Base64::encode_base64($image_content),
+                },
+                $image_name,
+                {
+                    FileName => 'Duplicate',
+                    FileType => 'image/png',
+                    FileContent => MIME::Base64::encode_base64($image_content),
+                },
+            ],
+        },
+    };
+
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+
+    if (RT::Handle::cmp_version($RT::VERSION, '4.2.5') >= 0) {
+        is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket with deletion of one value for multi-value image CF' to 'Ticket with non-unique values for multi-value image CF'", undef, "Duplicate added as a value for Multi Image CF"]);
+        is(scalar(@multi_image_ocfvs), 2);
+        is($multi_image_ocfvs[0]->Content, $image_name);
+        is($multi_image_ocfvs[0]->ContentType, 'image/png');
+        is($multi_image_ocfvs[0]->LargeContent, $image_content);
+        is($multi_image_ocfvs[1]->Content, 'Duplicate');
+        is($multi_image_ocfvs[1]->ContentType, 'image/png');
+        is($multi_image_ocfvs[1]->LargeContent, $image_content);
+    } else {
+        is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket with deletion of one value for multi-value image CF' to 'Ticket with non-unique values for multi-value image CF'", "$image_name added as a value for Multi Image CF", "Duplicate added as a value for Multi Image CF"]);
+        is(scalar(@multi_image_ocfvs), 3);
+        is($multi_image_ocfvs[0]->Content, $image_name);
+        is($multi_image_ocfvs[0]->ContentType, 'image/png');
+        is($multi_image_ocfvs[0]->LargeContent, $image_content);
+        is($multi_image_ocfvs[1]->Content, $image_name);
+        is($multi_image_ocfvs[1]->ContentType, 'image/png');
+        is($multi_image_ocfvs[1]->LargeContent, $image_content);
+        is($multi_image_ocfvs[2]->Content, 'Duplicate');
+        is($multi_image_ocfvs[2]->ContentType, 'image/png');
+        is($multi_image_ocfvs[2]->LargeContent, $image_content);
+    }
+}
+
 done_testing;
 

commit b1ea985bdcfd6210be79a232dd5d22312d8b6212
Author: gibus <gibus at easter-eggs.com>
Date:   Wed Oct 24 00:31:01 2018 +0200

    Allow binary ObjectCustomFieldValue upload as multipart/form-data for a resource
    
    Allow Image or Binary ObjectCustomFieldValue to be upload as multipart/form-data when creating or updating resource
    
    Add documentation for Image or Binary ObjectCustomFieldValue to be upload as multipart/form-data when creating or updating resource
    
    Based on public github PR #25

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index b791740..35fc37b 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -676,6 +676,28 @@ The reason why you should encode the content of the image or binary file to C<MI
         ]
     }
 
+Encoding the content of image or binary files in C<MIME Base64> has the drawback of adding some processing overhead and to increase the sent data size by around 33%. RT's REST2 API provides another way to upload image or binary files as custom field alues by sending, instead of a JSON request, a C<multipart/form-data> request. This kind of request is similar to what the browser sends when you upload a file in RT's ticket creation or update forms. As its name suggests, a C<multipart/form-data> request message contains a series of parts, each representing a form field. To create or update a ticket with image or binary file, the C<multipart/form-data> request has to include a field named C<Json>, which, as previously, is a JSON object with C<Queue>, C<Subject>, C<Content>, C<ContentType>, etc. properties. But instead of specifying each custom field value as a JSON object with C<FileName>, C<FileType> and C<FileContent> properties, each custom field value should be a JSON string startin
 g with keyword C<field:> followed by a field name. You can choose anything you want for this field name, except I<Attachement> or <attachment_1>, <attachment_2>, etc. which should be reserved for attching binary files to a response or a comment to a ticket. Files can then be attached by specifying a field named as specified in the C<CustomFields> property for each of them, with the content of the file as value and the appropriate MIME type.
+
+Here is an exemple of a curl invocation, wrapped to multiple lines for readability, to create a ticket with a multipart/request to upload some image or binary files as custom fields values.
+
+    curl -X POST
+         -H "Content-Type: multipart/form-data"
+         -F 'Json={
+                    "Queue"      : "General",
+                    "Subject"    : "hello world",
+                    "Content"    : "That <em>damned</em> printer is out of order <b>again</b>!",
+                    "ContentType": "text/html",
+                    "CustomFields"  : {
+                        "XX_SINGLE_IMAGE_OR_BINARY_CF_ID_XX"   => 'field: FILE_1',
+                        "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID" => ['field: FILE_2', 'field: FILE_3']
+                    }
+                  };type=application/json'
+         -F 'FILE_1=@/tmp/image.png;type=image/png'
+         -F 'FILE_1=@/tmp/another_image.png;type=image/png'
+         -F 'FILE_2=@/etc/cups/cupsd.conf;type=text/plain'
+         -H 'Authorization: token XX_TOKEN_XX'
+            'XX_RT_URL_XX'/tickets
+
 If you want to delete some existing values from a multi-value image or binary custom field, you can just pass the existing filename as value for the custom field identifier or name, no need to upload again the content of the file. The following example will delete the text file and keep the image upload in previous example:
 
     "CustomFields": {
diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index 2044872..ce59c43 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -21,11 +21,85 @@ sub create_path {
     $_[0]->record->id || undef
 }
 
-sub content_types_accepted { [ {'application/json' => 'from_json'} ] }
+sub content_types_accepted { [ {'application/json' => 'from_json'}, { 'multipart/form-data' => 'from_multipart' } ] }
+
+sub from_multipart {
+    my $self = shift;
+    my $json_str = $self->request->parameters->{Json};
+    return error_as_json(
+        $self->response,
+        \400, "Json is a required field for multipart/form-data")
+            unless $json_str;
+
+    my $json = JSON::decode_json($json_str);
+
+    my $cfs = delete $json->{CustomFields};
+    if ($cfs) {
+        foreach my $id (keys %$cfs) {
+            my $value = delete $cfs->{$id};
+
+            if (ref($value) eq 'ARRAY') {
+                my @values;
+                foreach my $single_value (@$value) {
+                    if ($single_value =~ /^field:\s*(.+)$/) {
+                        my $field_name = $1;
+                        my $file = $self->request->upload($field_name);
+                        if ($file) {
+                            open my $filehandle, '<', $file->tempname;
+                            if (defined $filehandle && length $filehandle) {
+                                my ( @content, $buffer );
+                                while ( my $bytesread = read( $filehandle, $buffer, 72*57 ) ) {
+                                    push @content, MIME::Base64::encode_base64($buffer);
+                                }
+                                close $filehandle;
+
+                                push @values, {
+                                    FileName    => $file->filename,
+                                    FileType    => $file->headers->{'content-type'},
+                                    FileContent => join("\n", @content),
+                                };
+                            }
+                        }
+                    }
+                    else {
+                        push @values, $single_value;
+                    }
+                }
+                $cfs->{$id} = \@values;
+            }
+            elsif ($value =~ /^field:\s*(.+)$/) {
+                my $field_name = $1;
+                my $file = $self->request->upload($field_name);
+                if ($file) {
+                    open my $filehandle, '<', $file->tempname;
+                    if (defined $filehandle && length $filehandle) {
+                        my ( @content, $buffer );
+                        while ( my $bytesread = read( $filehandle, $buffer, 72*57 ) ) {
+                            push @content, MIME::Base64::encode_base64($buffer);
+                        }
+                        close $filehandle;
+
+                        $cfs->{$id} = {
+                            FileName    => $file->filename,
+                            FileType    => $file->headers->{'content-type'},
+                            FileContent => join("\n", @content),
+                        };
+                    }
+                }
+            }
+            else {
+                $cfs->{$id} = $value;
+            }
+        }
+        $json->{CustomFields} = $cfs;
+    }
+
+    return $self->from_json($json);
+}
 
 sub from_json {
     my $self = shift;
-    my $params = JSON::decode_json( $self->request->content );
+    my $params = shift || JSON::decode_json( $self->request->content );
 
     %$params = (
         %$params,
diff --git a/lib/RT/Extension/REST2/Resource/Role/RequestBodyIsJSON.pm b/lib/RT/Extension/REST2/Resource/Role/RequestBodyIsJSON.pm
index 2d571cc..f9a0ece 100644
--- a/lib/RT/Extension/REST2/Resource/Role/RequestBodyIsJSON.pm
+++ b/lib/RT/Extension/REST2/Resource/Role/RequestBodyIsJSON.pm
@@ -25,6 +25,7 @@ role {
 
         my $request = $self->request;
         return 0 unless $request->method =~ /^(PUT|POST)$/;
+        return 0 unless $request->header('Content-Type') =~ /^application\/json/;
 
         my $json = eval {
             JSON::from_json($request->content)

commit 513dfa63df607fd3773b45443ab8bf6a74cfdb83
Author: gibus <gibus at easter-eggs.com>
Date:   Wed Oct 24 00:31:30 2018 +0200

    Tests for Binary ObjectCustomFieldValue upload as multipart/form-data
    
    Add tests for Image or Binary ObjectCustomFieldValue to be upload as multipart/form-data when creating or updating resource
    
    Based on public github PR #25

diff --git a/Makefile.PL b/Makefile.PL
index 44070da..70a827f 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -21,6 +21,7 @@ requires 'Web::Machine' => '0.12';
 requires 'Module::Path';
 requires 'Pod::POM';
 requires 'Path::Dispatcher' => '1.07';
+requires 'HTTP::Message' => '6.07';
 
 recommends 'JSON::XS';
 
diff --git a/xt/ticket-customfields.t b/xt/ticket-customfields.t
index f48d9f1..40d3cd7 100644
--- a/xt/ticket-customfields.t
+++ b/xt/ticket-customfields.t
@@ -384,7 +384,7 @@ for my $value (
     }
 }
 
-# Ticket Creation with image CF
+# Ticket Creation with image CF through JSON Base64
 my $image_name = 'image.png';
 my $image_path = RT::Test::get_relocatable_file($image_name, 'data');
 my $image_content;
@@ -429,7 +429,7 @@ my $image_cf_id = $image_cf->id;
     is($image_ocfv->LargeContent, $image_content);
 }
 
-# Ticket Update with image CF
+# Ticket Update with image CF through JSON Base64
 {
     # Ticket Creation with empty image CF
     my $payload = {
@@ -480,7 +480,7 @@ my $image_cf_id = $image_cf->id;
     is($image_ocfv->LargeContent, $image_content);
 }
 
-# Ticket Creation with multi-value image CF
+# Ticket Creation with multi-value image CF through JSON Base64
 my $multi_image_cf = RT::CustomField->new(RT->SystemUser);
 $multi_image_cf->Create(LookupType => 'RT::Queue-RT::Ticket', Name => 'Multi Image CF', Type => 'Image', MaxValues => 0, Queue => 'General');
 my $multi_image_cf_id = $multi_image_cf->id;
@@ -527,7 +527,7 @@ my $multi_image_cf_id = $multi_image_cf->id;
     is($multi_image_ocfvs[1]->LargeContent, $image_content);
 }
 
-# Ticket Update with multi-value image CF
+# Ticket Update with multi-value image CF through JSON Base64
 {
     # Ticket Creation with empty multi-value image CF
     my $payload = {
@@ -665,5 +665,261 @@ my $multi_image_cf_id = $multi_image_cf->id;
     }
 }
 
+# Ticket Creation with image CF through multipart/form-data
+my $json = JSON->new->utf8;
+{
+    my $payload = {
+        Subject => 'Ticket creation with image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket creation with multipart/form-data Image Custom Field using REST API.',
+        CustomFields => {
+            $image_cf_id => 'field: IMAGE',
+        },
+    };
+    no warnings 'once';
+    $HTTP::Request::Common::DYNAMIC_FILE_UPLOAD = 1;
+
+    my $res = $mech->post("$rest_base_path/ticket",
+        'Authorization' => $auth,
+        'Content_Type'  => 'form-data',
+        'Content'       => [
+            'Json'  => $json->encode($payload),
+            'IMAGE' => [$image_path, $image_name, 'Content-Type' => 'image/png'],
+        ]
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+    is($image_ocfv->Content, $image_name);
+    is($image_ocfv->ContentType, 'image/png');
+    is($image_ocfv->LargeContent, $image_content);
+}
+
+# Ticket Update with image CF through multipart/form-data
+{
+    # Ticket Creation with empty image CF
+    my $payload = {
+        Subject => 'Ticket creation with empty image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket update with multipart/form-data Image Custom Field using REST API.',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+    is($image_ocfv, undef);
+
+    # Ticket update with a value for image CF
+    $payload = {
+        Subject => 'Ticket with image CF',
+        CustomFields => {
+            $image_cf_id => 'field: IMAGE',
+        },
+    };
+    no warnings 'once';
+    $HTTP::Request::Common::DYNAMIC_FILE_UPLOAD = 1;
+
+    $res = $mech->put("$ticket_url",
+        'Authorization' => $auth,
+        'Content_Type'  => 'form-data',
+        'Content'       => [
+            'Json'  => $json->encode($payload),
+            'IMAGE' => [$image_path, $image_name, 'Content-Type' => 'image/png'],
+        ]
+    );
+
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket creation with empty image CF' to 'Ticket with image CF'", "Image CF $image_name added"]);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    $image_ocfv = $ticket->CustomFieldValues('Image CF')->First;
+    is($image_ocfv->Content, $image_name);
+    is($image_ocfv->ContentType, 'image/png');
+    is($image_ocfv->LargeContent, $image_content);
+}
+
+# Ticket Creation with multi-value image CF through multipart/form-data
+{
+    my $payload = {
+        Subject => 'Ticket creation with multi-value image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket creation with multipart/form-data Multi-Value Image Custom Field using REST API.',
+        CustomFields => {
+            $multi_image_cf_id => ['field: IMAGE_1', 'field: IMAGE_2'],
+        },
+    };
+
+    my $res = $mech->post("$rest_base_path/ticket",
+        'Authorization' => $auth,
+        'Content_Type'  => 'form-data',
+        'Content'       => [
+            'Json'  => $json->encode($payload),
+            'IMAGE_1' => [$image_path, $image_name, 'Content-Type' => 'image/png'],
+            'IMAGE_2' => [$image_path, 'Duplicate', 'Content-Type' => 'image/png'],
+        ]
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+    is(scalar(@multi_image_ocfvs), 2);
+    is($multi_image_ocfvs[0]->Content, $image_name);
+    is($multi_image_ocfvs[0]->ContentType, 'image/png');
+    is($multi_image_ocfvs[0]->LargeContent, $image_content);
+    is($multi_image_ocfvs[1]->Content, 'Duplicate');
+    is($multi_image_ocfvs[1]->ContentType, 'image/png');
+    is($multi_image_ocfvs[1]->LargeContent, $image_content);
+}
+
+# Ticket Update with multi-value image CF through multipart/form-data
+{
+    # Ticket Creation with empty multi-value image CF
+    my $payload = {
+        Subject => 'Ticket creation with empty multi-value image CF',
+        From    => 'test at bestpractical.com',
+        To      => 'rt at localhost',
+        Queue   => 'General',
+        Content => 'Testing ticket creation with multipart/form-data Multi-Value Image Custom Field using REST API.',
+    };
+
+    my $res = $mech->post_json("$rest_base_path/ticket",
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 201);
+    ok($ticket_url = $res->header('location'));
+    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+
+    my $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my $multi_image_ocfvs = $ticket->CustomFieldValues('Multi Image CF');
+    is($multi_image_ocfvs->Count, 0);
+
+    # Ticket update with two values for multi-value image CF
+    $payload = {
+        Subject => 'Ticket with multi-value image CF',
+        CustomFields => {
+            $multi_image_cf_id => ['field: IMAGE_1', 'field: IMAGE_2'],
+        },
+    };
+
+    $res = $mech->put($ticket_url,
+        'Authorization' => $auth,
+        'Content_Type'  => 'form-data',
+        'Content'       => [
+            'Json'  => $json->encode($payload),
+            'IMAGE_1' => [$image_path, $image_name, 'Content-Type' => 'image/png'],
+            'IMAGE_2' => [$image_path, 'Duplicate', 'Content-Type' => 'image/png'],
+        ]
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket creation with empty multi-value image CF' to 'Ticket with multi-value image CF'", "$image_name added as a value for Multi Image CF", "Duplicate added as a value for Multi Image CF"]);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    my @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+    is(scalar(@multi_image_ocfvs), 2);
+    is($multi_image_ocfvs[0]->Content, $image_name);
+    is($multi_image_ocfvs[0]->ContentType, 'image/png');
+    is($multi_image_ocfvs[0]->LargeContent, $image_content);
+    is($multi_image_ocfvs[1]->Content, 'Duplicate');
+    is($multi_image_ocfvs[1]->ContentType, 'image/png');
+    is($multi_image_ocfvs[1]->LargeContent, $image_content);
+
+    # Ticket update with deletion of one value for multi-value image CF
+    $payload = {
+        Subject => 'Ticket with deletion of one value for multi-value image CF',
+        CustomFields => {
+            $multi_image_cf_id => [ $image_name ],
+        },
+    };
+
+    $res = $mech->put($ticket_url,
+        'Authorization' => $auth,
+        'Content_Type'  => 'form-data',
+        'Content'       => [
+            'Json'  => $json->encode($payload),
+        ]
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket with multi-value image CF' to 'Ticket with deletion of one value for multi-value image CF'", "Duplicate is no longer a value for custom field Multi Image CF"]);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+    is(scalar(@multi_image_ocfvs), 1);
+    is($multi_image_ocfvs[0]->Content, $image_name);
+    is($multi_image_ocfvs[0]->ContentType, 'image/png');
+    is($multi_image_ocfvs[0]->LargeContent, $image_content);
+
+    # Ticket update with non-unique values for multi-value image CF
+    $payload = {
+        Subject => 'Ticket with non-unique values for multi-value image CF',
+        CustomFields => {
+            $multi_image_cf_id => ['field: IMAGE_1', $image_name, 'field: IMAGE_2'],
+        },
+    };
+
+    $res = $mech->put($ticket_url,
+        'Authorization' => $auth,
+        'Content_Type'  => 'form-data',
+        'Content'       => [
+            'Json'  => $json->encode($payload),
+            'IMAGE_1' => [$image_path, $image_name, 'Content-Type' => 'image/png'],
+            'IMAGE_2' => [$image_path, 'Duplicate', 'Content-Type' => 'image/png'],
+        ]
+    );
+    is($res->code, 200);
+
+    $ticket = RT::Ticket->new($user);
+    $ticket->Load($ticket_id);
+    @multi_image_ocfvs = @{$ticket->CustomFieldValues('Multi Image CF')->ItemsArrayRef};
+
+    if (RT::Handle::cmp_version($RT::VERSION, '4.2.5') >= 0) {
+        is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket with deletion of one value for multi-value image CF' to 'Ticket with non-unique values for multi-value image CF'", undef, "Duplicate added as a value for Multi Image CF"]);
+        is(scalar(@multi_image_ocfvs), 2);
+        is($multi_image_ocfvs[0]->Content, $image_name);
+        is($multi_image_ocfvs[0]->ContentType, 'image/png');
+        is($multi_image_ocfvs[0]->LargeContent, $image_content);
+        is($multi_image_ocfvs[1]->Content, 'Duplicate');
+        is($multi_image_ocfvs[1]->ContentType, 'image/png');
+        is($multi_image_ocfvs[1]->LargeContent, $image_content);
+    } else {
+        is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket with deletion of one value for multi-value image CF' to 'Ticket with non-unique values for multi-value image CF'", "$image_name added as a value for Multi Image CF", "Duplicate added as a value for Multi Image CF"]);
+        is(scalar(@multi_image_ocfvs), 3);
+        is($multi_image_ocfvs[0]->Content, $image_name);
+        is($multi_image_ocfvs[0]->ContentType, 'image/png');
+        is($multi_image_ocfvs[0]->LargeContent, $image_content);
+        is($multi_image_ocfvs[1]->Content, $image_name);
+        is($multi_image_ocfvs[1]->ContentType, 'image/png');
+        is($multi_image_ocfvs[1]->LargeContent, $image_content);
+        is($multi_image_ocfvs[2]->Content, 'Duplicate');
+        is($multi_image_ocfvs[2]->ContentType, 'image/png');
+        is($multi_image_ocfvs[2]->LargeContent, $image_content);
+    }
+}
+
 done_testing;
 

commit 111fc6314ffb9df5b4671a9157edfe0ca05f2ea2
Author: gibus <gibus at easter-eggs.com>
Date:   Wed Oct 24 01:51:07 2018 +0200

    Allow to delete a single ObjectCustomFieldValue
    
    Add documentation to delete a single ObjectCustomFieldValue
    
    Based on public github PR #26

diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index 35fc37b..c5b2c78 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -636,6 +636,12 @@ Note that for a multi-value custom field, you have to specify all the values for
         ]
     }
 
+To delete a single-value custom field, set its value to JSON C<null> (C<undef> in Perl):
+
+    "CustomFields": {
+        "XX_SINGLE_CF_ID_XX" : null
+    }
+
 New values for Image and Binary custom fields can be set by specifying a JSON object as value for the custom field identifier or name with the following properties:
 
 =over 4
diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index ce59c43..6f83d8c 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -151,7 +151,16 @@ sub _update_custom_fields {
 
         if ($cf->SingleValue) {
             my %args;
-            if (ref($val) eq 'ARRAY') {
+            my $old_val = $record->FirstCustomFieldValue($cfid);
+            if (!defined $val && $old_val) {
+                my ($ok, $msg) = $record->DeleteCustomFieldValue(
+                    Field => $cf,
+                    Value => $old_val,
+                );
+                push @results, $msg;
+                next;
+            }
+            elsif (ref($val) eq 'ARRAY') {
                 $val = $val->[0];
             }
             elsif (ref($val) eq 'HASH' && $cf->Type =~ /^(?:Image|Binary)$/) {

commit db00b3a6395e56b28e8a94823ffa7ec680f2c115
Author: gibus <gibus at easter-eggs.com>
Date:   Wed Oct 24 02:02:18 2018 +0200

    Add tests to delete a single ObjectCustomFieldValue
    
    Based on public github PR #26

diff --git a/xt/ticket-customfields.t b/xt/ticket-customfields.t
index 40d3cd7..ee77573 100644
--- a/xt/ticket-customfields.t
+++ b/xt/ticket-customfields.t
@@ -258,6 +258,44 @@ my ($ticket_url, $ticket_id);
 
     $content = $mech->json_response;
     is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified Again'], $multi_cf_id => [] }, 'Same CF value');
+
+    # fail to delete the CF if mandatory
+    $single_cf->SetPattern('(?#Mandatory).');
+    $payload->{Subject} = 'Cannot delete mandatory CF';
+    $payload->{CustomFields}{$single_cf_id} = undef;
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket 1: Subject changed from 'No CF change' to 'Cannot delete mandatory CF'", "Input must match [Mandatory]"]);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $content = $mech->json_response;
+    is_deeply($content->{CustomFields}, { $single_cf_id => ['Modified Again'], $multi_cf_id => [] }, 'Still same CF value');
+
+    # delete the CF
+    $single_cf->SetPattern();
+    $payload->{Subject} = 'Delete CF';
+    $payload->{CustomFields}{$single_cf_id} = undef;
+    $res = $mech->put_json($ticket_url,
+        $payload,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+    is_deeply($mech->json_response, ["Ticket 1: Subject changed from 'Cannot delete mandatory CF' to 'Delete CF'", 'Modified Again is no longer a value for custom field Single']);
+
+    $res = $mech->get($ticket_url,
+        'Authorization' => $auth,
+    );
+    is($res->code, 200);
+
+    $content = $mech->json_response;
+    is_deeply($content->{CustomFields}, { $single_cf_id => [], $multi_cf_id => [] }, 'No more CF value');
 }
 
 # Ticket Creation with ModifyCustomField
@@ -431,22 +469,18 @@ my $image_cf_id = $image_cf->id;
 
 # Ticket Update with image CF through JSON Base64
 {
-    # Ticket Creation with empty image CF
+    # Ticket update to delete image CF
     my $payload = {
-        Subject => 'Ticket creation with empty image CF',
-        From    => 'test at bestpractical.com',
-        To      => 'rt at localhost',
-        Queue   => 'General',
-        Content => 'Testing ticket update with Base64 encoded Image Custom Field using REST API.',
+        CustomFields => {
+            $image_cf_id => undef,
+        },
     };
 
-    my $res = $mech->post_json("$rest_base_path/ticket",
+    my $res = $mech->put_json($ticket_url,
         $payload,
         'Authorization' => $auth,
     );
-    is($res->code, 201);
-    ok($ticket_url = $res->header('location'));
-    ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+    is($res->code, 200);
 
     my $ticket = RT::Ticket->new($user);
     $ticket->Load($ticket_id);
@@ -470,7 +504,7 @@ my $image_cf_id = $image_cf->id;
         'Authorization' => $auth,
     );
     is($res->code, 200);
-    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket creation with empty image CF' to 'Ticket with image CF'", "Image CF $image_name added"]);
+    is_deeply($mech->json_response, ["Ticket $ticket_id: Subject changed from 'Ticket creation with image CF' to 'Ticket with image CF'", "Image CF $image_name added"]);
 
     $ticket = RT::Ticket->new($user);
     $ticket->Load($ticket_id);

-----------------------------------------------------------------------


More information about the Bps-public-commit mailing list