ABAP Playground: leggere API REST JSON

In questi giorni sto facendo un po' di esperimenti in ABAP Cloud su un account trial BTP, e ho scoperto che l'interfaccia IF_HTTP_CLIENT che uso di solito non è disponibile in cloud. Quindi ho voluto fare un rapido esperimento con la nuova IF_HTTP_WEB_CLIENT.

In questo esperimento proverò ad accedere alla famosa Launch Library, una API che permette di recuperare informazioni su lanci spaziali. La libreria è a pagamento (eccetto un limite free di 15 chiamate al giorno), ma è disponibile un endpoint di test (https://lldev.thespacedevs.com) con dati non aggiornati ma senza limiti di chiamate. La documentazione è qui.

Cominciamo.

Per cominciare creiamo una classe nel nostro ambiente ABAP Cloud che erediti l'interfaccia IF_OO_ADT_CLASSRUN. Questa interfaccia richiede l'implementazione di un metodo, e questo metodo può poi essere chiamato semplicemente premendo F9 dalla schermata di Eclipse; è previsto anche un metodo out->write che consente di stampare messaggi sulla console.

CLASS zmp_space DEFINITION
  PUBLIC
  FINAL
  CREATE PUBLIC .
  PUBLIC SECTION.
    INTERFACES: if_oo_adt_classrun.
  PROTECTED SECTION.
  PRIVATE SECTION.
ENDCLASS.

CLASS zmp_space IMPLEMENTATION.
  METHOD if_oo_adt_classrun~main.
  ENDMETHOD.
ENDCLASS.

La lettura di un endpoint passa attraverso alcuni step:

  • Predisposizione variabili (che è più elegante che dichiararle inline, a quanto dicono).
    DATA: lo_destination   TYPE REF TO if_http_destination,
          lo_client        TYPE REF TO if_web_http_client,
          lo_http_resp     TYPE REF TO if_web_http_response,
          lv_response_json TYPE string,
          lv_response_code TYPE i,
          cx               TYPE REF TO cx_root.
  • Creazione destinazione. Nel nostro caso la creiamo a partire da un URL fisso, ma il metodo consente di usare anche destinations o communication agreements creati nella BTP con autenticazione eccetera (ovviamente NON disponibili nella trial :-| ).
lo_destination = cl_http_destination_provider=>create_by_url( 'https://lldev.thespacedevs.com/2.2.0/launch/?limit=10' ).
  • Creazione dell'istanza client a partire dalla destinazione.
lo_client = cl_web_http_client_manager=>create_by_http_destination( lo_destination ).
  • Esecuzione della chiamata (passando il metodo da usare, nel nostro caso GET).
lo_http_resp = lo_client->execute( if_web_http_client=>get ).
  • Estrazione dell'esito HTTP e del testo contenuto nella risposta.
lv_response_code = lo_http_resp->get_status(  )-code.
lv_response_json = lo_http_resp->get_text(  ).
  • Chiusura del client.
lo_client->close(  ).
  • Eventuale verifica del buon esito della richiesta (codice 200).
IF ( lv_response_code NE 200 ).
  out->write( |Wrong response: { lv_response_code }| ).
  EXIT.
ENDIF.
  • ...il tutto in un blocco try..catch che non fa mai male.

Il codice completo ha più o meno questo aspetto.

    DATA: lo_destination   TYPE REF TO if_http_destination,
          lo_client        TYPE REF TO if_web_http_client,
          lo_http_resp     TYPE REF TO if_web_http_response,
          lv_response_json TYPE string,
          lv_response_code TYPE i,
          cx               TYPE REF TO cx_root.

    TRY.

        lo_destination = cl_http_destination_provider=>create_by_url( 'https://lldev.thespacedevs.com/2.2.0/launch/?limit=10' ).
        lo_client = cl_web_http_client_manager=>create_by_http_destination( lo_destination ).
        lo_http_resp = lo_client->execute( if_web_http_client=>get ).
        lv_response_code = lo_http_resp->get_status(  )-code.
        lv_response_json = lo_http_resp->get_text(  ).
        lo_client->close(  ).

      CATCH cx_root INTO cx.
        out->write( cx->get_longtext(  ) ).

    ENDTRY.

    IF ( lv_response_code NE 200 ).
      out->write( |Wrong response: { lv_response_code }| ).
      EXIT.
    ENDIF.

Se le cose sono andate per il verso giusto adesso nella variabile lv_response_json abbiamo il contenuto della risposta in formato json, che dobbiamo andare ad esaminare. Ha un aspetto del genere:

{
    "count": 6874,
    "next": "https://ll.thespacedevs.com/2.2.0/launch/?limit=10&offset=10",
    "previous": null,
    "results": [
        {
            "id": "e3df2ecd-c239-472f-95e4-2b89b4f75800",
            "url": "https://ll.thespacedevs.com/2.2.0/launch/e3df2ecd-c239-472f-95e4-2b89b4f75800/",
            "slug": "sputnik-8k74ps-sputnik-1",
            "name": "Sputnik 8K74PS | Sputnik 1",
            "status": {
                "id": 3,
                "name": "Launch Successful",
                "abbrev": "Success",
                "description": "The launch vehicle successfully inserted its payload(s) into the target orbit(s)."
            },
            "last_updated": "2023-01-21T01:14:08Z",
            "net": "1957-10-04T19:28:34Z",
            "window_end": "1957-10-04T19:28:34Z",
            "window_start": "1957-10-04T19:28:34Z",
            "probability": null,
            "holdreason": null,
            "failreason": null,
            "hashtag": null,
            "launch_service_provider": {
                "id": 66,
                "url": "https://ll.thespacedevs.com/2.2.0/agencies/66/",
                "name": "Soviet Space Program",
                "type": "Government"
            },
            "rocket": {
                "id": 3003,
                "configuration": {
                    "id": 468,
                    "url": "https://ll.thespacedevs.com/2.2.0/config/launcher/468/",
                    "name": "Sputnik 8K74PS",
                    "family": "Sputnik",
                    "full_name": "Sputnik 8K74PS",
                    "variant": "8K74PS"
                }
            },
            "mission": {
                "id": 1430,
                "name": "Sputnik 1",
                "description": "First artificial satellite consisting of a 58 cm pressurized aluminium shell containing two 1 W transmitters for a total mass of 83.6 kg.",
                "launch_designator": null,
                
                .....

Come si vede, la risposta contiene un array "results" che contiene i singoli lanci. Di ogni istanza di lancio ci interessa l'id, il "net" (Not Earlier Than, che in caso di lanci già avvenuti immagino rappresenti il timestamp dell'effettuazione) e il nome "name". Inoltre ogni lancio contiene un oggetto "mission", che a sua volta contiene un "description" con la descrizione della missione. Nel nostro esempio semplificato ci accontentiamo di estrarre queste informazioni.

Per prima cosa dobbiamo costruire una struttura di tipi adatta a contenere queste informazioni. Quindi un tipo che rappresenta la missione (con una variabile "description"), un tipo che rappresenta il lancio (contenente "id", "net", "name" e la struttura che abbiamo costruito prima sotto il nome "mission"). Infine un tipo che rappresenta la risposta complessiva, con all'interno una tabella di "results" che rappresenta l'array nel json. Infine definiamo una variabile lv_result costruita su questo tipo, per contenere la nostra risposta una volta parsata.

Una cosa del genere:

    TYPES:

      BEGIN OF ty_mission,
        description TYPE string,
      END OF ty_mission,

      BEGIN OF ty_launch,
        id      TYPE c LENGTH 40,
        name    TYPE string,
        net     TYPE string,
        mission TYPE ty_mission,
      END OF ty_launch,

      BEGIN OF ty_launch_response,
        count    TYPE string,
        results  TYPE SORTED TABLE OF ty_launch WITH UNIQUE KEY id, 
      END OF ty_launch_response.

    DATA: lv_result TYPE ty_launch_response.

A questo punto non ci resta che utilizzare il magico metodo "/ui2/cl_json=>deserialize" per trasformare il JSON in una struttura abap:

    /ui2/cl_json=>deserialize(
      EXPORTING
         json             = lv_response_json
      CHANGING
        data             = lv_result
    ).

Dopo avere attivato il codice (Ctrl+F3), possiamo mettere un punto di debug a valle del parsing (doppio click sulla sinistra del codice in Eclipse, apparirà un punto blu) e vedere cosa succede quando eseguiamo il codice in modalità terminale con F9. Tip: per fare esperimenti al volo si può aggiungere una istruzione inutile a valle del punto che ci interessa e mettere il punto di debug su quella.

Come si vede i dati ci sono tutti. Vogliamo stamparli a console?

    LOOP AT lv_result-results INTO DATA(lv_item).
      out->write( |{ lv_item-net WIDTH = 22 ALIGN = LEFT } { lv_item-name WIDTH = 70 ALIGN = LEFT PAD = ' ' } { lv_item-mission-description }| ).
    ENDLOOP.
2022-05-05T02:38:00Z   Long March 2D | Jilin-1 Wideband-01C & High Resolution 03D-27 to 33    Earth observation satellites for the Jilin-1 commercial Earth observation satellites constellation.      
2022-04-29T19:55:22Z   Angara 1.2 | Kosmos 2555 (MKA-R)                                       Russian military radar satellite.  
2022-05-09T17:56:30Z   Long March 7  | Tianzhou-4                                             Third cargo delivery mission to the Chinese large modular space station.  
2022-05-06T09:42:00Z   Falcon 9 Block 5 | Starlink Group 4-17                                 A batch of 53 satellites for Starlink mega-constellation - SpaceX's project for space-based Internet communication system.  
2022-05-02T22:49:52Z   Electron | There and Back Again                                        Commercial rideshare mission including payloads for Alba Orbital, Astrix Astronautics, Aurora Propulsion Technologies, E-Space, Spaceflight Inc, Swarm Technologies and UNSEENLABS.  
2022-04-29T21:27:10Z   Falcon 9 Block 5 | Starlink Group 4-16                                 A batch of 53 satellites for Starlink mega-constellation - SpaceX's project for space-based Internet communication system.  
2022-05-13T07:09:00Z   Hyperbola-1 | Jilin-1 Mofang-01A(R)                                    New generation experimental Earth imaging satellite for the Jilin-1 constellation.

Voilà!


Bonus: e se volessimo salvare in una tabella sul db queste informazioni? Possiamo creare una tabella custom con i campi che ci interessano (i campi di testo libero sono CLOB, identificati come abap.string(0))...

@EndUserText.label : 'ZMP_TBL_LAUNCHES'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zmp_tbl_launches {

  key client    : abap.clnt not null;
  key id        : abap.char(40) not null;
  name          : abap.string(0);
  net           : abap.string(0);
  description   : abap.string(0);

}

..e poi copiare il contenuto facendo un loop nei results. Per ciascuna iterazione copiamo i campi in una work area (una struttura costruita usando la tabella come modello) e usiamo il comando MODIFY, che crea la riga se non esiste e la aggiorna se esiste. Il controllo dell'esistenza è fatto sulla chiave della tabella (nel nostro caso id, oltre al solito client).

    DATA: wa_launch TYPE zmp_tbl_launches.

    LOOP AT lv_result-results INTO DATA(lv_launch_temp).

      CLEAR wa_launch.
      
      MOVE-CORRESPONDING lv_launch_temp TO wa_launch.

      wa_launch-description = lv_launch_temp-mission-description.

      MODIFY zmp_tbl_launches FROM @wa_launch.

    ENDLOOP.

Nell'esempio sopra abbiamo risparmiato un pò di fatica facendo un MOVE-CORRESPONDING, che copia i campi con nomi identici. Naturalmente non vale per "description", che si trova in una struttura all'interno di una colonna.

Dopo una esecuzione del programma la tabella contiene le informazioni.


Altro bonus: ABAP ha un timestamp standard strutturato come YYYYMMDDhhmmss, che è ovviamente diverso da quello restituito dall'API. Se vogliamo registrare il timestamp in un formato standard, per esempio per poter effettuare ricerche in range o ordinamenti, dobbiamo maltrattare la stringa restituita con un pò di string functions. Grezzamente farei una cosa del genere (sicuramente c'è un modo più elegante).

Aggiungiamo una colonna net_timestamp alla nostra tabella, con il tipo timestamp:

@EndUserText.label : 'ZMP_TBL_LAUNCHES'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zmp_tbl_launches {

  key client    : abap.clnt not null;
  key id        : abap.char(40) not null;
  name          : abap.string(0);
  net           : abap.string(0);
  net_timestamp : timestamp;
  description   : abap.string(0);

}

Modifichiamo il loop di cui sopra per aggiungere la creazione del timestamp. Per creare il timestamp preleviamo le due parti relative a data e ora, e da ciascuna rimuoviamo i caratteri separatori. Concateniamo il risultato ed abbiamo il nostro dato.

Originale: 1958-02-01T03:47:56Z

Data: 1958-02-01 -> 19580201
Ora: 03:47:56Z -> 034756

Concatenando: 19580201034756

L'estrazione delle sottostringhe si fa con la tradizionale notazione STRINGA+OFFSET(LUNGHEZZA), e la sostituzione con la tradizionale REPLACE ALL OCCURRENCES. Si, esistono moderne funzioni equivalenti che fanno molto meno anni 80, ma vogliamo mettere la nostalgia del cobol che nasce sfogliando questi programmi?

    DATA: wa_launch TYPE zmp_tbl_launches.
    DATA: lv_tmp_date TYPE c LENGTH 10,
          lv_tmp_time TYPE c LENGTH 8,
          lv_net_ts type timestamp.

    DELETE FROM zmp_tbl_launches.
    LOOP AT lv_result-results INTO DATA(lv_launch_temp).

      CLEAR wa_launch.
      MOVE-CORRESPONDING lv_launch_temp TO wa_launch.

      wa_launch-description = lv_launch_temp-mission-description.

      lv_tmp_date = lv_launch_temp-net(10).
      REPLACE ALL OCCURRENCES OF '-' IN lv_tmp_date WITH ''.

      lv_tmp_time = lv_launch_temp-net+11(8).
      REPLACE ALL OCCURRENCES OF ':' IN lv_tmp_time WITH ''.

      lv_net_ts = |{ lv_tmp_date }{ lv_tmp_time }|.
      wa_launch-net_timestamp = lv_net_ts.

      MODIFY zmp_tbl_launches FROM @wa_launch.

    ENDLOOP.

Ed ecco il risultato:

Alla prossima!