SAP ERP, SAP ABAP Development

How to use Google sheets APIs in ABAP with server to server authentification

Introduction

The purpose of this blog is to show you how to create/modify/delete google sheets via background programs from SAP.

Since these are background jobs run by the SAP system, it is not up to a user to authenticate to google since there is no human action. So we cannot choose the methods where the user authenticates directly to perform the extraction to google like this for example:

To work with this method, you have to look at the documentation here:

https://www.sap.com/documents/2018/07/56e0dd6d-0f7d-0010-87a3-c30de2ffd8ff.html

In the case of server to server connection, we will work in this way:

Development

  • Create a Google Cloud Platform project:
  • Within the project, create a service account

Navigate to IAM&Admin -> Service accounts and create a new service account. Type in a name and description and press create. On the next screen you have to select a role.

Type in IAP in the search and select the role IAP-secured Web App User.

  • AND GET THE KEY IN P12 FORMAT

After your service account created, you can create your key in P12 format

The P12 file download should start to your local machine. The service account allows access to the cloud resources, so store it securely. Note the shown secret for later use.

  • Import service account certificate to STRUST

Before we can write ABAP code and consume Google API, we have to make sure two things: First, we need to import the P12 file into the AS ABAP system, second we have to make sure, google is a trusted source for communication.

  • Create new SSF Application

We have to create a new entry in table SSFAPPLIC. Go to transaction SE16 and open the table. Create a new entry.

Use JWR_SI for APPLIC and select everything except B_INCCERTS, B_DETACHED, B_ASKPWD. As Description we set JWT Signature. This entry will be later a new node in transaction STRUST where we can import certificates. Save the new entry.

Next open transaction SSFA. Press “New Entries”. Create a new entry as shown below

We now have a new node in transaction STRUST.

  • Import Certificates into STRUST

Open transaction STRUST and a new node should be available with name “SSF JWT Signature”.

Go into “Edit” mode, right click the new node and select “Create”.

In the next window put Algorithm to “RSA” Key Strength to “2048” and Signature Algorithm to “SHA256”.

Confirm the selection and the new node will now be available for imports. From the top menu select “PSE->Import” and select the service account P12 file you have downloaded. You might need to enter the secret that was shown when downloading the P12 file from GCP.

Now the P12 file is loaded into the “File” node in STRUST. Next, we need to move it from “File” node to the right SSF Application. On the top menu select “PSE->Save as”. In the next window select “SSF Application” and select the SSF application we have created in the previous steps (JWR_SI).

Confirm the selection and press save. With this, we have now imported the Service Account P12 file into the ABAP AS and can use it to sign our JWT for requests to GCP. With STRUST we have a secure place to store the service account private key and certificate information.

We have now the GCP service account private key and certificate imported into the system.

  • Create the connection in SAP

Next, we need to make Google to be a trusted source for communication. This can be achieved by importing the Google Root CA into STRUST.

Download the Google certificate needed from the browser (see with your SAP BASIS team). In transaction STRUST on SSL client SSL Client (Anonymous).

Google is now a trusted source for communication and we have created the HTTP connections that will be used for communication in our ABAP code later.

Note : You can test your connection by clicking on “Connection test” :

You should have a 404 error (it’s normal)

If you have an SSL error message, then you have a issue with your Google certificates.

Try to see with your SAP BASIS Team the errors in ICM logs (TCODE SMICM)

  • Create the ABAP methods that will :

– Create and sign a JWT token in SAP

– Send it to Google for exchange against an authentication token to be placed in the header of each API call to google resources (drive, sheets, docs, etc…)

  • Create class ZCL_GCP_API_HANDLER
CLASS zcl_gcp_api_handler DEFINITION
  PUBLIC
  FINAL
  CREATE PUBLIC .

  PUBLIC SECTION.

      TYPES:

     BEGIN OF zgcp_response,
        content TYPE string,
        cookies TYPE tihttpcki,
        code type i,
        reason type string,
      END OF zgcp_response.

    CLASS-METHODS create_rs256_signed_jwt
      IMPORTING
                iv_jwt_header               TYPE zgcp_jwt_header
                iv_jwt_payload              TYPE zgcp_jwt_payload
                iv_ssf_profilename          TYPE string
                iv_ssf_id                   TYPE string
                iv_ssf_result               TYPE i
      RETURNING VALUE(rv_signed_jwt_base64) TYPE string.
*      RAISING   zcx_gcp_api_handler.

    CLASS-METHODS exchange_jwt_with_oidc_token
      IMPORTING
                iv_exchange_destination TYPE c
                iv_jwt_token            TYPE string
      RETURNING VALUE(rv_oidc_base64)   TYPE string.
*      RAISING   zcx_gcp_api_handler.

    CLASS-METHODS do_api_request
      IMPORTING
                iv_destination     TYPE c
                iv_oidc_token      TYPE string
                iv_method          TYPE string
                iv_xcontent        TYPE xstring OPTIONAL
                iv_content         TYPE string  OPTIONAL
                iv_sub_uri         TYPE string  OPTIONAL
                it_header_fields   TYPE tihttpnvp OPTIONAL
                it_cookies         TYPE tihttpcki OPTIONAL
                iv_content_type    TYPE string DEFAULT 'application/json'
      RETURNING VALUE(rs_response) TYPE zgcp_response.
*      RAISING   zcx_gcp_api_handler.

    CLASS-METHODS get_iat_unixtime RETURNING VALUE(rv_iat) TYPE int4.

  PROTECTED SECTION.

  PRIVATE SECTION.

    TYPES:
      ltty_tssfbin TYPE STANDARD TABLE OF ssfbin WITH KEY table_line WITHOUT FURTHER SECONDARY KEYS,

      BEGIN OF oidc_token_json,
        access_token TYPE string,
      END OF oidc_token_json.

    CLASS-METHODS string_to_binary_tab
      IMPORTING
                iv_string         TYPE string
      RETURNING VALUE(rt_bin_tab) TYPE ltty_tssfbin.
*      RAISING   zcx_gcp_api_handler.

    CLASS-METHODS binary_tab_to_string
      IMPORTING
                it_bin_tab       TYPE ltty_tssfbin
                iv_length        TYPE ssflen
      RETURNING VALUE(rv_string) TYPE string.
*      RAISING   zcx_gcp_api_handler.

    CLASS-METHODS base64_url_encode
      CHANGING
        iv_base64 TYPE string.
ENDCLASS.



CLASS ZCL_GCP_API_HANDLER IMPLEMENTATION.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Private Method ZCL_GCP_API_HANDLER=>BASE64_URL_ENCODE
* +-------------------------------------------------------------------------------------------------+
* | [<-->] IV_BASE64                      TYPE        STRING
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD base64_url_encode.
    REPLACE ALL OCCURRENCES OF '=' IN iv_base64 WITH ''.
    REPLACE ALL OCCURRENCES OF '+' IN iv_base64 WITH '-'.
    REPLACE ALL OCCURRENCES OF '/' IN iv_base64 WITH '_'.
  ENDMETHOD.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Private Method ZCL_GCP_API_HANDLER=>BINARY_TAB_TO_STRING
* +-------------------------------------------------------------------------------------------------+
* | [--->] IT_BIN_TAB                     TYPE        LTTY_TSSFBIN
* | [--->] IV_LENGTH                      TYPE        SSFLEN
* | [<-()] RV_STRING                      TYPE        STRING
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD binary_tab_to_string.
    CALL FUNCTION 'SCMS_BINARY_TO_STRING'
      EXPORTING
        input_length = iv_length
        encoding     = '4110'
      IMPORTING
        text_buffer  = rv_string
      TABLES
        binary_tab   = it_bin_tab
      EXCEPTIONS
        failed       = 1
        OTHERS       = 2.
*    IF sy-subrc <> 0.
*      RAISE EXCEPTION TYPE zcx_gcp_api_handler
*        EXPORTING
*          textid = zcx_gcp_api_handler=>zcx_bintostr_conversion_failed.
*    ENDIF.
  ENDMETHOD.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Public Method ZCL_GCP_API_HANDLER=>CREATE_RS256_SIGNED_JWT
* +-------------------------------------------------------------------------------------------------+
* | [--->] IV_JWT_HEADER                  TYPE        ZGCP_JWT_HEADER
* | [--->] IV_JWT_PAYLOAD                 TYPE        ZGCP_JWT_PAYLOAD
* | [--->] IV_SSF_PROFILENAME             TYPE        STRING
* | [--->] IV_SSF_ID                      TYPE        STRING
* | [--->] IV_SSF_RESULT                  TYPE        I
* | [<-()] RV_SIGNED_JWT_BASE64           TYPE        STRING
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD create_rs256_signed_jwt.
    DATA lt_input_bin TYPE STANDARD TABLE OF ssfbin.
    DATA lt_output_bin TYPE STANDARD TABLE OF ssfbin.
    DATA lv_input_length TYPE ssflen.
    DATA lv_output_length TYPE ssflen.
    DATA lv_output_crc TYPE ssfreturn.
    DATA lt_signer TYPE STANDARD TABLE OF ssfinfo.
    DATA lv_unix_iat TYPE string.

    DATA(lv_jwt_payload) = /ui2/cl_json=>serialize(
         data  = iv_jwt_payload
         pretty_name = /ui2/cl_json=>pretty_mode-low_case
    ).
    DATA(lv_jwt_header) = /ui2/cl_json=>serialize(
           data  = iv_jwt_header
           pretty_name = /ui2/cl_json=>pretty_mode-low_case
    ).

    DATA(lv_jwt_header_base64) = cl_http_utility=>encode_base64( unencoded = lv_jwt_header ).
    DATA(lv_jwt_payload_base64) = cl_http_utility=>encode_base64( unencoded = lv_jwt_payload ).

    DATA(lv_data_base64) = |{ lv_jwt_header_base64 }.{ lv_jwt_payload_base64 }|.
    base64_url_encode(
      CHANGING
        iv_base64 = lv_data_base64
    ).

    TRY.
        lt_input_bin = string_to_binary_tab( iv_string = lv_data_base64 ).
*      CATCH zcx_gcp_api_handler INTO DATA(lo_cx).
*        RAISE EXCEPTION TYPE zcx_gcp_api_handler
*          EXPORTING
*            textid = lo_cx->textid.
    ENDTRY.

    lt_signer = VALUE #( ( id = iv_ssf_id profile = iv_ssf_profilename result = iv_ssf_result ) ).

    lv_input_length = strlen( lv_data_base64 ).

    CALL FUNCTION 'SSF_KRN_SIGN'
      EXPORTING
        str_format                   = 'PKCS1-V1.5'
        b_inc_certs                  = abap_false
        b_detached                   = abap_false
        b_inenc                      = abap_false
        ostr_input_data_l            = lv_input_length
        str_hashalg                  = 'SHA256'
      IMPORTING
        ostr_signed_data_l           = lv_output_length
        crc                          = lv_output_crc    " SSF Return code
      TABLES
        ostr_input_data              = lt_input_bin
        signer                       = lt_signer
        ostr_signed_data             = lt_output_bin
      EXCEPTIONS
        ssf_krn_error                = 1
        ssf_krn_noop                 = 2
        ssf_krn_nomemory             = 3
        ssf_krn_opinv                = 4
        ssf_krn_nossflib             = 5
        ssf_krn_signer_list_error    = 6
        ssf_krn_input_data_error     = 7
        ssf_krn_invalid_par          = 8
        ssf_krn_invalid_parlen       = 9
        ssf_fb_input_parameter_error = 10.
*    IF sy-subrc <> 0.
*      RAISE EXCEPTION TYPE zcx_gcp_api_handler
*        EXPORTING
*          textid = zcx_gcp_api_handler=>zcx_signature_failed.
*    ENDIF.

    TRY.
        DATA(lv_signature) = binary_tab_to_string(
                        it_bin_tab = lt_output_bin
                        iv_length  = lv_output_length
                    ).
*      CATCH zcx_gcp_api_handler INTO DATA(lo_zcx).
*        RAISE EXCEPTION TYPE zcx_gcp_api_handler
*          EXPORTING
*            textid = lo_zcx->textid.
    ENDTRY.

    DATA(lv_jwt) = |{ lv_data_base64 }.{ cl_http_utility=>encode_base64( unencoded = lv_signature ) }|.
    base64_url_encode(
      CHANGING
        iv_base64 = lv_jwt
    ).

    rv_signed_jwt_base64 = lv_jwt.
  ENDMETHOD.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Public Method ZCL_GCP_API_HANDLER=>DO_API_REQUEST
* +-------------------------------------------------------------------------------------------------+
* | [--->] IV_DESTINATION                 TYPE        C
* | [--->] IV_OIDC_TOKEN                  TYPE        STRING
* | [--->] IV_METHOD                      TYPE        STRING
* | [--->] IV_XCONTENT                    TYPE        XSTRING(optional)
* | [--->] IV_CONTENT                     TYPE        STRING(optional)
* | [--->] IV_SUB_URI                     TYPE        STRING(optional)
* | [--->] IT_HEADER_FIELDS               TYPE        TIHTTPNVP(optional)
* | [--->] IT_COOKIES                     TYPE        TIHTTPCKI(optional)
* | [--->] IV_CONTENT_TYPE                TYPE        STRING (default ='application/json')
* | [<-()] RS_RESPONSE                    TYPE        ZGCP_RESPONSE
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD do_api_request.
    DATA lo_client_api  TYPE REF TO if_http_client.
    DATA lv_response TYPE string.
    DATA lv_oidc TYPE string.

    CALL METHOD cl_http_client=>create_by_destination
      EXPORTING
        destination              = iv_destination
      IMPORTING
        client                   = lo_client_api
      EXCEPTIONS
        argument_not_found       = 1
        destination_not_found    = 2
        destination_no_authority = 3
        plugin_not_active        = 4
        internal_error           = 5
        OTHERS                   = 6.
*    IF sy-subrc <> 0.
*      RAISE EXCEPTION TYPE zcx_gcp_api_handler
*        EXPORTING
*          textid = zcx_gcp_api_handler=>zcx_api_dest_not_found.
*    ENDIF.

    IF lo_client_api IS BOUND.
      lv_oidc = |Bearer { iv_oidc_token }|.

      lo_client_api->request->set_header_fields( fields = it_header_fields ).

      lo_client_api->request->set_content_type( content_type = iv_content_type ).
      lo_client_api->request->set_method( method = iv_method ).

*         set jwt token auth
      lo_client_api->request->set_header_field(
          name  = 'Authorization' ##NO_TEXT
          value = lv_oidc
      ).
      lo_client_api->request->set_header_field(
          name  = 'content-type'
          value = iv_content_type
      ).


      IF iv_sub_uri IS NOT INITIAL.
        cl_http_utility=>set_request_uri(
            request = lo_client_api->request
            uri     = iv_sub_uri
        ).
      ENDIF.

      IF iv_xcontent IS NOT INITIAL.
        lo_client_api->request->set_data( data = iv_xcontent ).
      ENDIF.

      IF iv_content IS NOT INITIAL.
        lo_client_api->request->set_cdata( data = iv_content ).
      ENDIF.

      LOOP AT it_cookies ASSIGNING FIELD-SYMBOL(<cookie>).
        lo_client_api->request->set_cookie(
          EXPORTING
            name    = <cookie>-name                 " Name of cookie
            path    = <cookie>-path               " Path of Cookie
            value   = <cookie>-value                 " Cookie value
            domain  = <cookie>-xdomain               " Domain Name of Cookie
            expires = <cookie>-expires               " Cookie expiry date
            secure  = <cookie>-secure                " 0: unsaved; 1:saved
        ).
      ENDLOOP.

      lo_client_api->send( ).
      lo_client_api->receive(
        EXCEPTIONS
          http_communication_failure = 1
          http_invalid_state         = 2
          http_processing_failed     = 3
      ).
*      IF sy-subrc <> 0.
*        RAISE EXCEPTION TYPE zcx_gcp_api_handler
*          EXPORTING
*            textid = zcx_gcp_api_handler=>zcx_api_receive_failed.
*      ENDIF.

      rs_response-content = lo_client_api->response->get_data( ).
      lo_client_api->response->get_status( IMPORTING code   = rs_response-code
                                                     reason = rs_response-reason ).
      lo_client_api->response->get_cookies( CHANGING cookies = rs_response-cookies ).
    ENDIF.
  ENDMETHOD.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Public Method ZCL_GCP_API_HANDLER=>EXCHANGE_JWT_WITH_OIDC_TOKEN
* +-------------------------------------------------------------------------------------------------+
* | [--->] IV_EXCHANGE_DESTINATION        TYPE        C
* | [--->] IV_JWT_TOKEN                   TYPE        STRING
* | [<-()] RV_OIDC_BASE64                 TYPE        STRING
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD exchange_jwt_with_oidc_token.
    DATA lo_client  TYPE REF TO if_http_client.
    DATA ls_response TYPE oidc_token_json.

    CALL METHOD cl_http_client=>create_by_destination
      EXPORTING
        destination              = iv_exchange_destination
      IMPORTING
        client                   = lo_client
      EXCEPTIONS
        argument_not_found       = 1
        destination_not_found    = 2
        destination_no_authority = 3
        plugin_not_active        = 4
        internal_error           = 5
        OTHERS                   = 6.
*    IF sy-subrc <> 0.
*      RAISE EXCEPTION TYPE zcx_gcp_api_handler
*        EXPORTING
*          textid = zcx_gcp_api_handler=>zcx_oauth_dest_not_found.
*    ENDIF.

    IF lo_client IS BOUND.
      lo_client->request->set_method( if_http_request=>co_request_method_post ).
      lo_client->request->set_formfield_encoding( formfield_encoding = if_http_entity=>co_formfield_encoding_encoded ).

      lo_client->request->set_form_field(
        EXPORTING
          name  = 'grant_type'
          value = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
      ).

      lo_client->request->set_form_field(
        EXPORTING
          name  = 'assertion'
          value = iv_jwt_token
      ).

      lo_client->send( ).
      lo_client->receive(
        EXCEPTIONS
          http_communication_failure = 1
          http_invalid_state         = 2
          http_processing_failed     = 3
      ).
*      IF sy-subrc <> 0.
*        RAISE EXCEPTION TYPE zcx_gcp_api_handler
*          EXPORTING
*            textid = zcx_gcp_api_handler=>zcx_oauth_token_receive_fail.
*      ENDIF.

      DATA(lv_response_json) = lo_client->response->get_cdata( ).

      /ui2/cl_json=>deserialize(
        EXPORTING
          json = lv_response_json
          pretty_name = /ui2/cl_json=>pretty_mode-camel_case
        CHANGING data = ls_response ).

*      IF ls_response-id_token IS INITIAL.
*        RAISE EXCEPTION TYPE zcx_gcp_api_handler
*          EXPORTING
*            textid = zcx_gcp_api_handler=>zcx_oauth_token_receive_fail.
*      ENDIF.

      rv_oidc_base64 = ls_response-access_token.
    ENDIF.
  ENDMETHOD.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Public Method ZCL_GCP_API_HANDLER=>GET_IAT_UNIXTIME
* +-------------------------------------------------------------------------------------------------+
* | [<-()] RV_IAT                         TYPE        INT4
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD get_iat_unixtime.
    DATA lv_unix_iat TYPE string.

    GET TIME STAMP FIELD DATA(lv_timestamp).

    CONVERT TIME STAMP lv_timestamp TIME ZONE 'UTC' INTO DATE DATA(lv_date) TIME DATA(lv_time).

    cl_pco_utility=>convert_abap_timestamp_to_java(
      EXPORTING
        iv_date      = lv_date
        iv_time      = lv_time
        iv_msec      = 0
      IMPORTING
        ev_timestamp = lv_unix_iat
    ).

    rv_iat = substring( val = lv_unix_iat off = 0 len = strlen( lv_unix_iat ) - 3 ).
  ENDMETHOD.


* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Private Method ZCL_GCP_API_HANDLER=>STRING_TO_BINARY_TAB
* +-------------------------------------------------------------------------------------------------+
* | [--->] IV_STRING                      TYPE        STRING
* | [<-()] RT_BIN_TAB                     TYPE        LTTY_TSSFBIN
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD string_to_binary_tab.
    DATA lv_xstring TYPE xstring.
    CALL FUNCTION 'SCMS_STRING_TO_XSTRING'
      EXPORTING
        text     = iv_string
        encoding = '4110'
      IMPORTING
        buffer   = lv_xstring
      EXCEPTIONS
        failed   = 1
        OTHERS   = 2.
*    IF sy-subrc <> 0.
*      RAISE EXCEPTION TYPE zcx_gcp_api_handler
*        EXPORTING
*          textid = zcx_gcp_api_handler=>zcx_strtobin_conversion_failed.
*    ENDIF.

    CALL FUNCTION 'SCMS_XSTRING_TO_BINARY'
      EXPORTING
        buffer     = lv_xstring
      TABLES
        binary_tab = rt_bin_tab.
  ENDMETHOD.
ENDCLASS.
  • Create 2 structures zgcp_jwt_header and zgcp_jwt_payload.

– ALG stands for Algorithm and will include the algorithm that is used for encryption which is RS256

– TYP stands for token type and will be JWT

– ISS stands for issuer and will be the name of our Google Service Account

– AUD stands for audience, basically the consumer of the token

– TARGET_AUDIENCE is the id of out OAUTH client at Google

– IAT stands for issued at time and is a timestamp in UNIX time, when we created the token

– EXP stands for expires and is a timestamp in UNIX time when the token will expire.

  • Create a program that will call the above methods to identify itself.
*&---------------------------------------------------------------------*
*& Report ZCL_GCP_JWT_AUTH_TEST
*&---------------------------------------------------------------------*
*&
*&---------------------------------------------------------------------*
REPORT ZCL_GCP_JWT_AUTH_TEST.

*1
DATA(lv_iat) = zcl_gcp_api_handler=>get_iat_unixtime( ).
    DATA(ls_jwt_payload) = VALUE zgcp_jwt_payload( iss = 'your email google service account'
*                                              aud = 'https://www.googleapis.com/oauth2/v4/token'
                                              scope = 'https://www.googleapis.com/auth/drive'
                                              aud = 'https://oauth2.googleapis.com/token'
*                                              target_audience = '110934787806204234349'
                                              iat = lv_iat
                                              exp = lv_iat + 30 ).

    DATA(ls_jwt_header) = VALUE zgcp_jwt_header( typ = 'JWT'
                                                 alg = 'RS256' ).

*2
    TRY.
        DATA(lv_signed_jwt) = zcl_gcp_api_handler=>create_rs256_signed_jwt(
          EXPORTING
            iv_jwt_header        = ls_jwt_header
            iv_jwt_payload       = ls_jwt_payload
            iv_ssf_profilename   = 'SAPJWR_SI400.pse'
            iv_ssf_id            = '<implicit>'
            iv_ssf_result        = 28
        ).
*      CATCH zcx_gcp_api_handler.
    ENDTRY.

*3
    TRY.
        DATA(lv_oidc) = zcl_gcp_api_handler=>exchange_jwt_with_oidc_token(
                        iv_exchange_destination = 'GCP_OAUTH2_TOKEN'
                        iv_jwt_token            = lv_signed_jwt
        ).
*      CATCH zcx_gcp_api_handler.
    ENDTRY.
  1. We specify all the variables necessary for the creation of the JWT and its signature
  2. Launching the program to generate and sign the JWT.
  3. Launch the class to generate the authentication token by sending the signed JWT to Google which returns a token. The google token is in lv_oidc variable

With this token, it is now possible to call google APIs by specifying the token in the call header.

Now we can create a test program calling the google sheets APIs with our authentication token

You can test your calls here :

https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update

By inspecting, it is possible to find the url to specify in the call to have this operation:

To see example calls in SAP, I created a test program :

Example for a clear sheet:

Example to add values in the spreadsheet:

Rating: 0 / 5 (0 votes)