2015-07-31

Apex Connector Framework触ってみた。

いつの間にか、Apex Connector Framework(Custom Adapter)

Dev組織で利用できるようになっていたので触ってみました!

 

Lightning ConnectはSalesforce環境から外部のデータにアクセスする機能で

別Salesforce組織のデータや、ODataに対応したAPI経由でデータを取得し

あたかもSalesforceのオブジェクトであるかのように扱うことが出来ます。

今回のリリースではApex Connector Framework(Custom Adapter)が利用できるようになりました。

これを使うと、閲覧するデータが別Salesforce組織のデータだったり、ODataに対応していなくても

Apexから取得できる手段であればガバナを超えない範囲において

様々なデータでも取得できるようになり、Salesforceのオブジェクトのような

振る舞いを持たせることができます。

 

今回はBoxのファイルデータを閲覧する外部データソースをApex Connector Frameworkを使って

定義し、SalesforceからBoxのファイル一覧を取得してみます。

参考URL→https://developer.salesforce.com/blogs/engineering/2015/05/introducing-lightning-connect-custom-adapters.html

1. Connectionクラスを作成

サンプルは以下の通り。
global class BoxDataSourceConnection extends DataSource.Connection {
    private DataSource.ConnectionParams params;
    global BoxDataSourceConnection(DataSource.ConnectionParams connectionParams) {
        this.params = connectionParams;
    }
    
    override global List<DataSource.Table> sync() {
        List<DataSource.Table> tables = new List<DataSource.Table>();
        List<DataSource.Column> columns;
        columns = new List<DataSource.Column>();
        columns.add(DataSource.Column.text('Name', 255));
        columns.add(DataSource.Column.text('Type', 255));
        columns.add(DataSource.Column.text('ExternalId', 255));
        columns.add(DataSource.Column.url('DisplayUrl', 255));
        columns.add(DataSource.Column.number('Size', 18, 0));
        columns.add(DataSource.Column.text('CreatedAt', 20));
        tables.add(DataSource.Table.get('Entries', 'Name', columns));
        return tables;
    }
    
    override global DataSource.TableResult query(DataSource.QueryContext context) {
        if (context.tableSelection.columnsSelected.size() == 1 &&
            context.tableSelection.columnsSelected.get(0).aggregation == DataSource.QueryAggregation.COUNT) {
                List<Map<String,Object>> rows = getRows(context);
                List<Map<String,Object>> response = DataSource.QueryUtils.filter(context, getRows(context));
                List<Map<String, Object>> countResponse = new List<Map<String, Object>>();
                Map<String, Object> countRow = new Map<String, Object>();
                countRow.put(
                    context.tableSelection.columnsSelected.get(0).columnName,
                    response.size());
                countResponse.add(countRow);
                return DataSource.TableResult.get(context, countResponse);
        } else {
            List<Map<String,Object>> filteredRows =
                DataSource.QueryUtils.filter(context, getRows(context));
            List<Map<String,Object>> sortedRows =
                DataSource.QueryUtils.sort(context, filteredRows);
            List<Map<String,Object>> limitedRows =
                DataSource.QueryUtils.applyLimitAndOffset(context,
                    sortedRows);
            return DataSource.TableResult.get(context, limitedRows);
        }
    }
    
    override global List<DataSource.TableResult> search(DataSource.SearchContext context) {
        List<DataSource.TableResult> results = new List<DataSource.TableResult>();
        for (DataSource.TableSelection tableSelection : context.tableSelections) {
            results.add(DataSource.TableResult.get(tableSelection, getRows(context)));
        }
        return results;
    }
    
    private List<Map<String,Object>> getRows(DataSource.ReadContext context) {
        HttpRequest req = new HttpRequest();
        req.setHeader('Content-Type', 'application/json; charset=UTF-8');
        req.setEndpoint('callout:Box/2.0/folders/0/items?fields=name,created_at,size,type');
        req.setMethod('GET');
        
        Http http = new Http();
        HTTPResponse res = http.send(req);
        Utility.sendErrorMail(req.getBody());
        List<Map<String, Object>> results = new List<Map<String, Object>>();
        Map<String, Object> response = 
            (Map<String, Object>)JSON.deserializeUntyped(res.getBody());
        
        for (Object item_obj : (List<Object>)response.get('entries')) {
            Map<String, Object> item = (Map<String, Object>)item_obj;
            Map<String, Object> row = new Map<String, Object>();
            row.put('Name', item.get('name'));
            row.put('Type', item.get('type'));
            row.put('CreatedAt', item.get('created_at'));
            row.put('Size', item.get('size'));
            row.put('ExternalId', item.get('id'));
            row.put('DisplayUrl', 'https://hoge.example.com');
            results.add(row);
        }
        return results;
    }
}

sync, query, searchを実装すればOKで、

syncは「検証して同期」をしたときのオブジェクト自動作成の設定で

queryはSOQL発行時の挙動、searchはSOSL発行時の挙動をそれぞれ定義します。

 

syncで作成するオブジェクト内には「Name」「ExternalId」「DisplayUrl」を必ず指定します。

 

DataSource.QueryContext内にはSOQLの抽出フィールド、抽出対象オブジェクト、抽出条件等が

全て格納されています。

サンプルコードではSOQL発行時にgetRowsでルートフォルダ直下のファイルを全件取得して

QueryUtilsのメソッドを使ってプログラム内でフィルタリングしています。

これだと効率が悪かったりガバナに引っかかる可能性が高いので

QueryContextで予め条件を解釈してからAPIを叩くのが理想だと思われます。

 

Providerクラスからコンストラクタ経由で渡されるConnectionParamsには

外部データソースの各種設定値が格納されています。

例えば、外部データソースで指定したURL(Capability.REQUIRE_ENDPOINT)は

ConnectionParamsのendpointプロパティとして取得できるようになります。

また、認証・認可方式にBasic認証を指定すると、username, password

OAuth2.0を指定するとoauthToken

証明書を指定するとcertificateNameがそれぞれ利用できるようになります。

APIをコールするときはこれらの情報を利用することになりますが、

面倒な上にデバッグログとかで認証情報が見られる危険性があるので

サンプルのようにNamed Credentialを使う方式がオススメ。

2. Providerクラスを作成

プロバイダの認証方法として選択可能なリストを定義したり、

外部システムが対応している操作や利用するConnectionクラスを定義します。

サンプルは以下のとおり。

global class BoxDataSourceProvider extends DataSource.Provider {
    override global List<DataSource.AuthenticationCapability> getAuthenticationCapabilities() {
        List<DataSource.AuthenticationCapability> capabilities = new List<DataSource.AuthenticationCapability>();
        capabilities.add(DataSource.AuthenticationCapability.OAUTH);
        return capabilities;
    }

    override global List<DataSource.Capability> getCapabilities() {
        List<DataSource.Capability> capabilities = new
            List<DataSource.Capability>();
        capabilities.add(DataSource.Capability.ROW_QUERY);
        capabilities.add(DataSource.Capability.SEARCH);
        return capabilities;
    }
    override global DataSource.Connection getConnection(DataSource.ConnectionParams connectionParams) {
        return new BoxDataSourceConnection(connectionParams);
    }
}

getAuthenticationCapabilitiesで対応している認証方式を定義します。

BoxだとOAuth2.0のみの対応なのでAuthenticationCapability.OAUTHを指定してます。

AuthenticationCapabilityのEnumは以下に定義されています。

https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_enum_DataSource_AuthenticationCapability.htm

 

getCapabilitiesでは、どの操作に対応しているかを定義し、

SOQL、SOSLに対応しているかどうかや、外部プロバイダの設定で

URLを指定できるかどうか等を指定します。

CapabilityのEnumは以下に定義されています。

https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_enum_DataSource_Capability.htm

 

最後にgetConnectionを定義して、1で作成したConnectionクラスを指定します。

3. 外部データソースの設定

正常にコンパイルされていれば、作成したProviderを指定することが出来ます。

external-datasource-custom

CapabilityにREQUIRE_ENDPOINTを指定するとこんな感じでURLが表示されます。

今回外部データソースの認証機構は利用しないので「匿名」のままで。

作成した後は通常通り「検証して同期」すればOK。

オブジェクトが出てこない場合は、Connectionのsyncメソッドの記述が間違っている可能性大。

4. データの閲覧

Boxのファイル一覧だとこんな感じに表示されます。

external-datasource-custom-entries

補足

Connection側を変更したらProvider側も再コンパイル(=空更新)する必要があります。

再コンパイルしないとこんなエラーが発生します。

external-datasource-error

所感

OData Providerを立てなくても外部リソースにアクセスしてSalesforceオブジェクトのように

扱えるのは強力な機能だと思います。

ちゃんとしたもの作りこむにはSOQLに応じて呼ぶAPIを変えるとか、ガバナ考慮するとか

色々と泥臭い処理を入れていくようになると思いますが、

一回作っておけば、色んな組織にコピペして使いまわせて、かなり便利なイメージ。

このエントリーをはてなブックマークに追加