diff --git a/lib/src/json_js.dart b/lib/src/json_js.dart index f7bead9..f90909c 100644 --- a/lib/src/json_js.dart +++ b/lib/src/json_js.dart @@ -1,4 +1,4 @@ -@JS('JSON') +@JS() library stringify; import 'package:js/js.dart'; @@ -11,5 +11,7 @@ import 'package:js/js.dart'; /// ``` js /// JSON.stringify() /// ``` -@JS('stringify') -external String stringify(Object obj); +@JS('JSON') +class JsonJS { + external static String stringify(Object obj); +} diff --git a/lib/src/insertion_order_parser.dart b/lib/src/public_api_parser.dart similarity index 86% rename from lib/src/insertion_order_parser.dart rename to lib/src/public_api_parser.dart index 1094bf7..83bd5ea 100644 --- a/lib/src/insertion_order_parser.dart +++ b/lib/src/public_api_parser.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'proto/insertion_order_query.pb.dart'; -class InsertionOrderParser { +/// TODO: parse and handle dv360 public api error. +/// Issue:https://github.com/googleinterns/dv360-excel-plugin/issues/52 +class PublicApiParser { static const _emptyEntry = ''; // Disregarding linter rule to ensure that enum maps are typed. @@ -36,10 +38,29 @@ class InsertionOrderParser { key: (v) => v.name, value: (v) => v); - /// Parses a json string to [InsertionOrder]. - static InsertionOrder parse(String jsonString) { + /// Parses a list of [InsertionOrder] from a [jsonString]. + /// + /// Returns an empty list if [jsonString] is null or empty. + static List parseInsertionOrders(String jsonString) { Map map = json.decode(jsonString); - return _createInsertionOrder(map); + + // If map contains key 'insertionOrders', multiple IOs are returned. + // And if it doesn't, the map itself represents one insertion order. + if (map.containsKey('insertionOrders')) { + return List.from(map['insertionOrders']) + .map((ioMap) => _createInsertionOrder(ioMap)) + .toList(); + } else { + return [_createInsertionOrder(map)]; + } + } + + /// Parses the nextPageToken from a [jsonString]. + /// + /// Returns a empty string if [jsonString] is null or empty. + static String parseNextPageToken(String jsonString) { + Map map = json.decode(jsonString); + return map['nextPageToken'] ?? _emptyEntry; } /// Creates an [InsertionOrder] instance from [map]. diff --git a/lib/src/query_component.dart b/lib/src/query_component.dart index db7fccf..1d1b77d 100644 --- a/lib/src/query_component.dart +++ b/lib/src/query_component.dart @@ -2,11 +2,12 @@ import 'package:angular/angular.dart'; import 'package:angular_forms/angular_forms.dart'; import 'excel.dart'; -import 'insertion_order_parser.dart'; -import 'json_js.dart' as json; +import 'public_api_parser.dart'; +import 'json_js.dart'; import 'proto/insertion_order_query.pb.dart'; import 'query_service.dart'; import 'reporting_query_parser.dart'; +import 'util.dart'; @Component( selector: 'query', @@ -16,14 +17,25 @@ import 'reporting_query_parser.dart';
+ + +
+ +
+
+ ''', - providers: [ClassProvider(QueryService), ClassProvider(ExcelDart)], + providers: [ + ClassProvider(QueryService), + ClassProvider(ExcelDart), + FORM_PROVIDERS, + ], directives: [coreDirectives, formDirectives], ) class QueryComponent implements OnInit { @@ -31,19 +43,39 @@ class QueryComponent implements OnInit { final QueryService _queryService; final ExcelDart _excel; + QueryType _queryType; + String advertiserId; String insertionOrderId; - bool highlightUnderpacing = false; + // Radio button states with byAdvertiser selected as default. + RadioButtonState advertiserQuery = RadioButtonState(true, 'byAdvertiser'); + RadioButtonState insertionOrderQuery = RadioButtonState(false, 'byIO'); + QueryComponent(this._queryService, this._excel); @override void ngOnInit() async => await _excel.loadOffice(); void onClick() async { + // Determines the query type from radio buttons. + _queryType = advertiserQuery.checked + ? QueryType.byAdvertiser + : QueryType.byInsertionOrder; + // Uses DV360 public APIs to fetch entity data. - final insertionOrder = await _queryAndParseInsertionOrderEntityData(); + var insertionOrders = await _queryAndParseInsertionOrderEntityData(); + + // If [_queryType.byAdvertiser], filters out insertion orders + // that are not in-flight. + insertionOrders = _queryType == QueryType.byAdvertiser + ? _filterInFlightInsertionOrders(insertionOrders) + : insertionOrders; + + // TODO: update reporting query to deal with multiple IOs. + // Issue: https://github.com/googleinterns/dv360-excel-plugin/issues/61 + final insertionOrder = insertionOrders.first; // Gets dateRange for the active budget segment. final activeDateRange = insertionOrder.budget.activeBudgetSegment.dateRange; @@ -55,16 +87,31 @@ class QueryComponent implements OnInit { insertionOrder.spent = revenueMap[insertionOrder.insertionOrderId] ?? ''; // Populate the spreadsheet. - await _excel.populate([insertionOrder], highlightUnderpacing); + await _excel.populate(insertionOrders, highlightUnderpacing); } /// Fetches insertion order entity related data using DV360 public APIs, - /// then return a parsed [InsertionOrder] instance. - Future _queryAndParseInsertionOrderEntityData() async { - final response = - await _queryService.execDV3Query(advertiserId, insertionOrderId); - - return InsertionOrderParser.parse(json.stringify(response)); + /// then return a list of parsed [InsertionOrder] instance. + Future> _queryAndParseInsertionOrderEntityData() async { + final insertionOrderList = []; + var jsonResponse = '{}'; + + do { + // Gets the nextPageToken, and having an empty token + // doesn't affect the query. + final nextPageToken = PublicApiParser.parseNextPageToken(jsonResponse); + + // Executes dv360 query, parses the response and adds results to the list. + final response = await _queryService.execDV3Query( + _queryType, nextPageToken, advertiserId, insertionOrderId); + jsonResponse = JsonJS.stringify(response); + + // Adds all insertion orders in this iteration to the list. + insertionOrderList + .addAll(PublicApiParser.parseInsertionOrders(jsonResponse)); + } while (PublicApiParser.parseNextPageToken(jsonResponse).isNotEmpty); + + return insertionOrderList; } /// Fetches revenue spent data using DBM reporting APIs, @@ -75,7 +122,7 @@ class QueryComponent implements OnInit { final jsonCreateQueryResponse = await _queryService .execReportingCreateQuery(advertiserId, insertionOrderId, dateRange); final reportingQueryId = ReportingQueryParser.parseQueryIdFromJsonString( - json.stringify(jsonCreateQueryResponse)); + JsonJS.stringify(jsonCreateQueryResponse)); try { // Uses the queryId to get the report download path. @@ -83,7 +130,7 @@ class QueryComponent implements OnInit { await _queryService.execReportingGetQuery(reportingQueryId); final reportingDownloadPath = ReportingQueryParser.parseDownloadPathFromJsonString( - json.stringify(jsonGetQueryResponse)); + JsonJS.stringify(jsonGetQueryResponse)); // Downloads the report and parse the response into a revenue map. final report = @@ -96,4 +143,13 @@ class QueryComponent implements OnInit { return {}; } } + + List _filterInFlightInsertionOrders( + List insertionOrders) { + return insertionOrders + .where((io) => + io.budget.activeBudgetSegment != + InsertionOrder_Budget_BudgetSegment()) + .toList(); + } } diff --git a/lib/src/query_service.dart b/lib/src/query_service.dart index 11f1989..788a179 100644 --- a/lib/src/query_service.dart +++ b/lib/src/query_service.dart @@ -6,6 +6,7 @@ import 'package:fixnum/fixnum.dart'; import 'gapi.dart'; import 'proto/insertion_order_query.pb.dart'; +import 'util.dart'; @Injectable() class QueryService { @@ -17,14 +18,13 @@ class QueryService { /// /// Completer is used here to convert the callback method of [execute] into /// a future, so that we only proceed when the request finishes executing. - Future execDV3Query( + /// Having an empty [nextPageToken] will not affect the query. + Future execDV3Query(QueryType queryType, String nextPageToken, String advertiserId, String insertionOrderId) async { - final dv3RequestArgs = RequestArgs( - path: _generateQuery(advertiserId, insertionOrderId), method: 'GET'); - final responseCompleter = Completer(); GoogleAPI.client - .request(dv3RequestArgs) + .request(_generateDV3Query( + queryType, nextPageToken, advertiserId, insertionOrderId)) .execute(allowInterop((jsonResp, rawResp) { responseCompleter.complete(jsonResp); })); @@ -114,8 +114,25 @@ class QueryService { } /// Generates query based on user inputs. - static String _generateQuery(String advertiserId, String insertionOrderId) { - return 'https://displayvideo.googleapis.com/v1/advertisers/$advertiserId/' - 'insertionOrders/$insertionOrderId'; + static RequestArgs _generateDV3Query(QueryType queryType, + String nextPageToken, String advertiserId, String insertionOrderId) { + switch (queryType) { + case QueryType.byAdvertiser: + final filter = 'filter=entityStatus="ENTITY_STATUS_ACTIVE"'; + final pageToken = 'pageToken=$nextPageToken'; + return RequestArgs( + path: 'https://displayvideo.googleapis.com/v1/advertisers/' + '$advertiserId/insertionOrders?$filter&$pageToken', + method: 'GET'); + + case QueryType.byInsertionOrder: + return RequestArgs( + path: 'https://displayvideo.googleapis.com/v1/advertisers/' + '$advertiserId/insertionOrders/$insertionOrderId', + method: 'GET'); + + default: + return RequestArgs(); + } } } diff --git a/lib/src/util.dart b/lib/src/util.dart index 8d7c8d2..1a25fea 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -1,5 +1,7 @@ import 'package:fixnum/fixnum.dart'; +enum QueryType { byAdvertiser, byInsertionOrder } + class Util { /// Convert micros to string in standard unit. static String convertMicrosToStandardUnitString(Int64 micros) { diff --git a/test/insertion_order_parser_test.dart b/test/public_api_parser_test.dart similarity index 62% rename from test/insertion_order_parser_test.dart rename to test/public_api_parser_test.dart index 3d6d55f..382c99a 100644 --- a/test/insertion_order_parser_test.dart +++ b/test/public_api_parser_test.dart @@ -1,10 +1,10 @@ import 'package:angular_test/angular_test.dart'; -import 'package:dv360_excel_plugin/src/insertion_order_parser.dart'; +import 'package:dv360_excel_plugin/src/public_api_parser.dart'; import 'package:dv360_excel_plugin/src/proto/insertion_order_query.pb.dart'; import 'package:test/test.dart'; void main() { - group(InsertionOrderParser, () { + group(PublicApiParser, () { String input; const emptyEntry = ''; @@ -41,42 +41,51 @@ void main() { tearDown(disposeAnyRunningTest); - test( - 'parse insertionOrder from empty json should return ' - 'an empty insertionOrder instance', () { - input = '{}'; + group('parses insertion orders from:', () { + test('null', () { + final actual = PublicApiParser.parseInsertionOrders(null); - final actual = InsertionOrderParser.parse(input); + final expected = []; + expect(actual, expected); + }); - final expected = InsertionOrder(); - expect(actual, expected); - }); + test('empty string', () { + final actual = PublicApiParser.parseInsertionOrders(''); - test( - 'parse insertionOrder from json that contains only advertiserId ' - 'should return an instance with only advertiserId set', () { - input = '{"advertiserId":"111111"}'; + final expected = []; + expect(actual, expected); + }); - final actual = InsertionOrderParser.parse(input); + test('empty json string', () { + final actual = PublicApiParser.parseInsertionOrders('{}'); - final expected = insertionOrderTemplate..advertiserId = '111111'; - expect(actual, expected); - }); + final expected = [InsertionOrder()]; + expect(actual, expected); + }); + + test('json that contains only advertiserId', () { + input = '{"advertiserId":"111111"}'; - test('parse insertionOrder from json that contains everything', () { - final advertiserId = '"advertiserId":"11111"'; - final campaignId = '"campaignId":"2222222"'; - final insertionOrderId = '"insertionOrderId":"3333333"'; - final displayName = '"displayName":"display name"'; - final entityStatus = '"entityStatus":"ENTITY_STATUS_ACTIVE"'; - final updateTime = '"updateTime":"2020-06-23T17:14:58.685Z"'; - final pacing = ''' + final actual = PublicApiParser.parseInsertionOrders(input); + + final expected = insertionOrderTemplate..advertiserId = '111111'; + expect(actual, [expected]); + }); + + test('json that contains everything', () { + final advertiserId = '"advertiserId":"11111"'; + final campaignId = '"campaignId":"2222222"'; + final insertionOrderId = '"insertionOrderId":"3333333"'; + final displayName = '"displayName":"display name"'; + final entityStatus = '"entityStatus":"ENTITY_STATUS_ACTIVE"'; + final updateTime = '"updateTime":"2020-06-23T17:14:58.685Z"'; + final pacing = ''' "pacing":{ "pacingPeriod":"PACING_PERIOD_FLIGHT", "pacingType":"PACING_TYPE_AHEAD" } '''; - final budgetSegment = ''' + final budgetSegment = ''' "budgetSegments":[ {"budgetAmountMicros":"4000000", "description":"year-2019", @@ -93,51 +102,92 @@ void main() { } ] '''; - final budget = ''' + final budget = ''' "budget":{ "budgetUnit":"BUDGET_UNIT_CURRENCY", "automationType":"INSERTION_ORDER_AUTOMATION_TYPE_NONE", $budgetSegment } '''; - input = '{$advertiserId, $campaignId, $insertionOrderId, $displayName,' - '$entityStatus, $updateTime, $pacing, $budget}'; - - final actual = InsertionOrderParser.parse(input); - - final expectedPacing = InsertionOrder_Pacing() - ..pacingPeriod = InsertionOrder_Pacing_PacingPeriod.PACING_PERIOD_FLIGHT - ..pacingType = InsertionOrder_Pacing_PacingType.PACING_TYPE_AHEAD - ..dailyMaxImpressions = emptyEntry; - final segment = InsertionOrder_Budget_BudgetSegment() - ..budgetAmountMicros = '2000000' - ..description = emptyEntry - ..campaignBudgetId = emptyEntry - ..dateRange = oneDateRange; - final expectedBudget = InsertionOrder_Budget() - ..budgetUnit = InsertionOrder_Budget_BudgetUnit.BUDGET_UNIT_CURRENCY - ..automationType = InsertionOrder_Budget_InsertionOrderAutomationType - .INSERTION_ORDER_AUTOMATION_TYPE_NONE - ..activeBudgetSegment = segment; - final expected = InsertionOrder() - ..advertiserId = '11111' - ..campaignId = '2222222' - ..insertionOrderId = '3333333' - ..displayName = 'display name' - ..updateTime = '2020-06-23T17:14:58.685Z' - ..entityStatus = InsertionOrder_EntityStatus.ENTITY_STATUS_ACTIVE - ..pacing = expectedPacing - ..budget = expectedBudget; - expect(actual, expected); + input = '{$advertiserId, $campaignId, $insertionOrderId, $displayName,' + '$entityStatus, $updateTime, $pacing, $budget}'; + + final actual = PublicApiParser.parseInsertionOrders(input); + + final expectedPacing = InsertionOrder_Pacing() + ..pacingPeriod = + InsertionOrder_Pacing_PacingPeriod.PACING_PERIOD_FLIGHT + ..pacingType = InsertionOrder_Pacing_PacingType.PACING_TYPE_AHEAD + ..dailyMaxImpressions = emptyEntry; + final segment = InsertionOrder_Budget_BudgetSegment() + ..budgetAmountMicros = '2000000' + ..description = emptyEntry + ..campaignBudgetId = emptyEntry + ..dateRange = oneDateRange; + final expectedBudget = InsertionOrder_Budget() + ..budgetUnit = InsertionOrder_Budget_BudgetUnit.BUDGET_UNIT_CURRENCY + ..automationType = InsertionOrder_Budget_InsertionOrderAutomationType + .INSERTION_ORDER_AUTOMATION_TYPE_NONE + ..activeBudgetSegment = segment; + final expected = InsertionOrder() + ..advertiserId = '11111' + ..campaignId = '2222222' + ..insertionOrderId = '3333333' + ..displayName = 'display name' + ..updateTime = '2020-06-23T17:14:58.685Z' + ..entityStatus = InsertionOrder_EntityStatus.ENTITY_STATUS_ACTIVE + ..pacing = expectedPacing + ..budget = expectedBudget; + expect(actual, [expected]); + }); }); - group('parse Pacing from json that contains:', () { + group('parses next page token from:', () { + test('null', () { + final actual = PublicApiParser.parseNextPageToken(null); + + final expected = emptyEntry; + expect(actual, expected); + }); + + test('empty string', () { + final actual = PublicApiParser.parseNextPageToken(''); + + final expected = emptyEntry; + expect(actual, expected); + }); + + test('empty json string', () { + final actual = PublicApiParser.parseNextPageToken('{}'); + + final expected = emptyEntry; + expect(actual, expected); + }); + + test('json that does not contain next page token', () { + input = '{"some-field": "some-value", "not-token" : "not_token_value"}'; + final actual = PublicApiParser.parseNextPageToken(input); + + final expected = emptyEntry; + expect(actual, expected); + }); + + test('json that contains next page token', () { + input = '{"some-field": "some-value", "nextPageToken" : "token_value"}'; + final actual = PublicApiParser.parseNextPageToken(input); + + final expected = 'token_value'; + expect(actual, expected); + }); + }); + + group('parses Pacing from json that contains:', () { test('nothing', () { input = '{"pacing":{}}'; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); - expect(actual, insertionOrderTemplate); + expect(actual, [insertionOrderTemplate]); }); test('pacingPeriod and pacingType', () { @@ -150,7 +200,7 @@ void main() { } '''; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final pacing = InsertionOrder_Pacing() ..pacingPeriod = @@ -158,7 +208,7 @@ void main() { ..pacingType = InsertionOrder_Pacing_PacingType.PACING_TYPE_AHEAD ..dailyMaxImpressions = emptyEntry; final expected = insertionOrderTemplate..pacing = pacing; - expect(actual, expected); + expect(actual, [expected]); }); test('pacingPeriod, pacingType and dailyMaxMicros', () { @@ -172,7 +222,7 @@ void main() { } '''; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final pacing = InsertionOrder_Pacing() ..pacingPeriod = @@ -181,23 +231,23 @@ void main() { ..dailyMaxMicros = '1500000' ..dailyMaxImpressions = emptyEntry; final expected = insertionOrderTemplate..pacing = pacing; - expect(actual, expected); + expect(actual, [expected]); }); }); - group('parse InsertionOrderBudget from json that contains:', () { + group('parses InsertionOrderBudget from json that contains:', () { test('nothing', () { input = '{"budget":{}}'; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); - expect(actual, insertionOrderTemplate); + expect(actual, [insertionOrderTemplate]); }); test('budgetUnit', () { input = '{"budget":{"budgetUnit":"BUDGET_UNIT_CURRENCY"}}'; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final budget = InsertionOrder_Budget() ..budgetUnit = InsertionOrder_Budget_BudgetUnit.BUDGET_UNIT_CURRENCY @@ -205,7 +255,7 @@ void main() { .INSERTION_ORDER_AUTOMATION_TYPE_NONE ..activeBudgetSegment = InsertionOrder_Budget_BudgetSegment(); final expected = insertionOrderTemplate..budget = budget; - expect(actual, expected); + expect(actual, [expected]); }); test('budgetUnit and automationType', () { @@ -218,7 +268,7 @@ void main() { } '''; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final budget = InsertionOrder_Budget() ..budgetUnit = InsertionOrder_Budget_BudgetUnit.BUDGET_UNIT_CURRENCY @@ -226,7 +276,7 @@ void main() { .INSERTION_ORDER_AUTOMATION_TYPE_BUDGET ..activeBudgetSegment = InsertionOrder_Budget_BudgetSegment(); final expected = insertionOrderTemplate..budget = budget; - expect(actual, expected); + expect(actual, [expected]); }); test('empty budgetSegment', () { @@ -240,7 +290,7 @@ void main() { } '''; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final budget = InsertionOrder_Budget() ..budgetUnit = InsertionOrder_Budget_BudgetUnit.BUDGET_UNIT_CURRENCY @@ -248,7 +298,7 @@ void main() { .INSERTION_ORDER_AUTOMATION_TYPE_BUDGET ..activeBudgetSegment = InsertionOrder_Budget_BudgetSegment(); final expected = insertionOrderTemplate..budget = budget; - expect(actual, expected); + expect(actual, [expected]); }); test('one budgetSegment that is active', () { @@ -265,7 +315,7 @@ void main() { '''; input = '{"budget":{$budgetSegment}}'; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final segment = InsertionOrder_Budget_BudgetSegment() ..budgetAmountMicros = '5000000' @@ -279,7 +329,7 @@ void main() { .INSERTION_ORDER_AUTOMATION_TYPE_NONE ..activeBudgetSegment = segment; final expected = insertionOrderTemplate..budget = budget; - expect(actual, expected); + expect(actual, [expected]); }); test('multiple budgetSegments that contains one active', () { @@ -308,7 +358,7 @@ void main() { '''; input = '{"budget":{$budgetSegment}}'; - final actual = InsertionOrderParser.parse(input); + final actual = PublicApiParser.parseInsertionOrders(input); final segment = InsertionOrder_Budget_BudgetSegment() ..budgetAmountMicros = '4000000' @@ -322,7 +372,7 @@ void main() { .INSERTION_ORDER_AUTOMATION_TYPE_NONE ..activeBudgetSegment = segment; final expected = insertionOrderTemplate..budget = budget; - expect(actual, expected); + expect(actual, [expected]); }); }); });