[Bps-public-commit] rt-extension-rest2 branch, master, updated. 1.07-43-gcaeb9bf

? sunnavy sunnavy at bestpractical.com
Fri May 1 15:01:15 EDT 2020


The branch, master has been updated
       via  caeb9bf64562f65720f6f11d69dda1f6aba28b03 (commit)
       via  5491857b22b274ce1abb003aa59ce0fcf287cf6b (commit)
       via  1e2bd04ce5ccb56fb540263534a910278d2bb04e (commit)
       via  863fa9a35235027bd83b6afa3b29ff650ca9252b (commit)
       via  7e9ae6fdfe672f0e9829ed3b313686a3ac905304 (commit)
       via  06395a18727d729d16eff0605d31ea718b715453 (commit)
       via  3b2f7776810b9e53963ed0876740adc400569a18 (commit)
       via  397ee2c98094e6f64c7f005f1502d619c47d4ad7 (commit)
       via  1918b5307b511f98ae1cf959cb100a8995b8b07d (commit)
      from  6b9d3a89e96d6a98750d3775cf1a84f727793853 (commit)

Summary of changes:
 README                                             | 164 ++++++
 lib/RT/Extension/REST2.pm                          | 171 ++++++
 .../REST2/Resource/ObjectCustomFieldValue.pm       |  57 ++
 lib/RT/Extension/REST2/Resource/Record/Writable.pm | 106 +++-
 .../REST2/Resource/Role/RequestBodyIsJSON.pm       |   1 +
 lib/RT/Extension/REST2/Util.pm                     |  58 ++-
 xt/cf-image.t                                      |  72 +++
 xt/ticket-customfields.t                           | 577 ++++++++++++++++++++-
 8 files changed, 1200 insertions(+), 6 deletions(-)
 create mode 100644 lib/RT/Extension/REST2/Resource/ObjectCustomFieldValue.pm
 create mode 100644 xt/cf-image.t

- Log -----------------------------------------------------------------
commit 1918b5307b511f98ae1cf959cb100a8995b8b07d
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

diff --git a/README b/README
index 9b49e36..90e1873 100644
--- a/README
+++ b/README
@@ -485,6 +485,10 @@ USAGE
         GET /attachment/:id
             retrieve an attachment
 
+   Image and Binary Object Custom Field Values
+        GET /download/cf/:id
+            retrieve an image or a binary file as an object custom field value
+
    Queues
         GET /queues/all
             retrieve list of all queues you can see
diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index e91273b..c8eed32 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -538,6 +538,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..4623a11
--- /dev/null
+++ b/lib/RT/Extension/REST2/Resource/ObjectCustomFieldValue.pm
@@ -0,0 +1,57 @@
+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 397ee2c98094e6f64c7f005f1502d619c47d4ad7
Author: gibus <gibus at easter-eggs.com>
Date:   Mon Oct 22 22:38:05 2018 +0200

    Add tests for downloading image or binary ObjectCustomFieldValue

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;

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

    Allow Image or Binary as ocfv to be upload as JSON Base64 encoded content

diff --git a/README b/README
index 90e1873..690a87d 100644
--- a/README
+++ b/README
@@ -771,6 +771,117 @@ 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 resource 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
+    resource 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, mandatory.
+
+    FileType
+        The MIME type of the file to attach, mandatory.
+
+    FileContent
+        The content, *encoded in MIME Base64* of the file to attach,
+        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 c8eed32..b7ed45a 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -840,6 +840,123 @@ 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 resource 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 resource
+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, mandatory.
+
+=item C<FileType>
+
+The MIME type of the file to attach, mandatory.
+
+=item C<FileContent>
+
+The content, I<encoded in C<MIME Base64>> of the file to attach, 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 de38a97..cb65095 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -241,6 +241,36 @@ sub create_record {
     # Lookup CustomFields by name.
     if ($cfs) {
         foreach my $id (keys(%$cfs)) {
+			my $value = 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++;
+                }
+            }
+            $cfs->{$id} = $value;
+
             if ($id !~ /^\d+$/) {
                 my $cf = $record->LoadCustomFieldByIdentifier($id);
 
diff --git a/lib/RT/Extension/REST2/Util.pm b/lib/RT/Extension/REST2/Util.pm
index 2cc03ba..dcb76c0 100644
--- a/lib/RT/Extension/REST2/Util.pm
+++ b/lib/RT/Extension/REST2/Util.pm
@@ -280,9 +280,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";
             }
@@ -290,15 +306,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;
@@ -322,6 +364,7 @@ sub update_custom_fields {
                         my ($ok, $msg) = $record->AddCustomFieldValue(
                             Field => $cf,
                             Value => $key,
+                            $args{$key} ? %{$args{$key}} : (),
                         );
                         push @results, $msg;
                     }

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

    Add tests for binary ObjectCustomFieldValue upload as base64

diff --git a/xt/ticket-customfields.t b/xt/ticket-customfields.t
index 326237f..5ca91fb 100644
--- a/xt/ticket-customfields.t
+++ b/xt/ticket-customfields.t
@@ -562,5 +562,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 7e9ae6fdfe672f0e9829ed3b313686a3ac905304
Author: gibus <gibus at easter-eggs.com>
Date:   Wed Oct 24 00:31:01 2018 +0200

    Allow binary ObjectCustomFieldValue upload as multipart/form-data

diff --git a/README b/README
index 690a87d..af2f823 100644
--- a/README
+++ b/README
@@ -847,6 +847,48 @@ USAGE
             ]
         }
 
+    Encoding the content of image or binary files in 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 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 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
+    multipart/form-data request has to include a field named JSON, which, as
+    previously, is a JSON object with Queue, Subject, Content, ContentType,
+    etc. properties. But instead of specifying each custom field value as a
+    JSON object with FileName, FileType and FileContent properties, each
+    custom field value should be a JSON object with UploadField. You can
+    choose anything you want for this field name, except *Attachments*,
+    which should be reserved for attaching files to a response or a comment
+    to a ticket. Files can then be attached by specifying a field named as
+    specified in the 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"   => { "UploadField": "FILE_1",
+                            "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID" => [ { "UploadField": "FILE_2" }, { "UploadField": "FILE_3" } ]
+                        }
+                      };type=application/json'
+             -F 'FILE_1=@/tmp/image.png;type=image/png'
+             -F 'FILE_2=@/tmp/another_image.png;type=image/png'
+             -F 'FILE_3=@/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
diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index b7ed45a..e3a9d2f 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -922,6 +922,48 @@ passed in the C<FileContent> property.
         ]
     }
 
+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 object with C<UploadField>. You can
+choose anything you want for this field name, except I<Attachments>, which
+should be reserved for attaching 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"   => { "UploadField": "FILE_1",
+                        "XX_MULTI_VALUE_IMAGE_OR_BINARY_CF_ID" => [ { "UploadField": "FILE_2" }, { "UploadField": "FILE_3" } ]
+                    }
+                  };type=application/json'
+         -F 'FILE_1=@/tmp/image.png;type=image/png'
+         -F 'FILE_2=@/tmp/another_image.png;type=image/png'
+         -F 'FILE_3=@/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
diff --git a/lib/RT/Extension/REST2/Resource/Record/Writable.pm b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
index cb65095..3a4b763 100644
--- a/lib/RT/Extension/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/Extension/REST2/Resource/Record/Writable.pm
@@ -21,11 +21,83 @@ 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 ( ref $single_value eq 'HASH' && ( my $field_name = $single_value->{UploadField} ) ) {
+                        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 ( ref $value eq 'HASH' && ( my $field_name = $value->{UploadField} ) ) {
+                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 863fa9a35235027bd83b6afa3b29ff650ca9252b
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

diff --git a/xt/ticket-customfields.t b/xt/ticket-customfields.t
index 5ca91fb..23123f0 100644
--- a/xt/ticket-customfields.t
+++ b/xt/ticket-customfields.t
@@ -562,7 +562,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;
@@ -607,7 +607,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 = {
@@ -658,7 +658,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;
@@ -705,7 +705,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 = {
@@ -843,5 +843,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 => { UploadField => '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 => { UploadField => '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 => [ { UploadField => 'IMAGE_1' }, { UploadField => '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 => [ { UploadField => 'IMAGE_1' }, { UploadField => '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 => [ { UploadField => 'IMAGE_1' }, $image_name, { UploadField => '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 1e2bd04ce5ccb56fb540263534a910278d2bb04e
Merge: 6b9d3a8 863fa9a
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat May 2 02:54:03 2020 +0800

    Merge branch 'binary-custom-fields'


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

    Allow to delete values for single-value custom fields

diff --git a/README b/README
index af2f823..a6e7830 100644
--- a/README
+++ b/README
@@ -804,6 +804,13 @@ USAGE
             ]
         }
 
+    To delete a single-value custom field, set its value to JSON null (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:
diff --git a/lib/RT/Extension/REST2.pm b/lib/RT/Extension/REST2.pm
index e3a9d2f..41378dd 100644
--- a/lib/RT/Extension/REST2.pm
+++ b/lib/RT/Extension/REST2.pm
@@ -873,6 +873,13 @@ following example deletes "My Second Value" from the previous example:
         ]
     }
 
+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:
diff --git a/lib/RT/Extension/REST2/Util.pm b/lib/RT/Extension/REST2/Util.pm
index dcb76c0..6a2ba19 100644
--- a/lib/RT/Extension/REST2/Util.pm
+++ b/lib/RT/Extension/REST2/Util.pm
@@ -281,7 +281,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 caeb9bf64562f65720f6f11d69dda1f6aba28b03
Author: gibus <gibus at easter-eggs.com>
Date:   Wed Oct 24 02:02:18 2018 +0200

    Add tests to delete values of single-value custom fields

diff --git a/xt/ticket-customfields.t b/xt/ticket-customfields.t
index 23123f0..8454160 100644
--- a/xt/ticket-customfields.t
+++ b/xt/ticket-customfields.t
@@ -360,6 +360,48 @@ my $no_ticket_cf_values = bag(
 
     $content = $mech->json_response;
     cmp_deeply($content->{CustomFields}, $modified_again_single_cf_value, '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;
+    cmp_deeply($content->{CustomFields}, $modified_again_single_cf_value, '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);
+    cmp_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);
+
+    $modified_again_single_cf_value = bag(
+        { name => 'Single', id => $single_cf_id, type => 'customfield', _url => ignore(), values => [] },
+        { name => 'Multi',  id => $multi_cf_id,  type => 'customfield', _url => ignore(), values => [] },
+    );
+    $content = $mech->json_response;
+    cmp_deeply($content->{CustomFields}, $modified_again_single_cf_value, 'No more CF value');
 }
 
 # Ticket Comment with custom field
@@ -390,7 +432,7 @@ my $no_ticket_cf_values = bag(
         'Authorization' => $auth,
     );
     is($res->code, 201);
-    cmp_deeply($mech->json_response, [re(qr/Comments added|Message recorded/), "Single Modified Again changed to Yet another modified CF"]);
+    cmp_deeply($mech->json_response, [re(qr/Comments added|Message recorded/), "Single Yet another modified CF added"]);
 }
 
 # Ticket Creation with ModifyCustomField
@@ -609,22 +651,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);
@@ -648,7 +686,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