Recently, one of my clients was working with a new data source in Power BI that required multiple calls to a REST API to iterate through an unknown number of pages to retrieve JSON documents. We were able to use a combination of parameters and M code to dynamically construct the URI necessary to call a series of pages, and everything worked fine while working in the Power BI Desktop.
However, when the PBIX file was published to the Power BI service, the data set could not be refreshed because the data source was unsupported. Sad trombone…
The most promising clue to a resolution was found in a post by my friend Chris Webb (b|t), Web.Contents(), M Functions And Dataset Refresh Errors In Power BI. Chris describes how to deal with resolving the refresh error when the query parameters change. In my case, I needed the base URL to change. Our scenario was just enough different that I thought a post to document our approach might be useful to someone someday.
Background
The problem relates to the manner in which we need to dynamically build a URI to download JSON documents. Although the examples in this post refer to my client’s cloud-based applicant tracking system, JazzHR, the same scenario could apply to many other different REST APIs, so I’ll focus more on the structure of the URI and the M language in Power BI rather than JazzHR specifically.
My client wants to analyze applicant data in Power BI that can be obtained through the JazzHR API. By using a specifically structured URI, you can download data from JazzHR for the first 100 applicants. Because the information is secure, you must supply an API key to access your data as a query parameter in the URI, like this:
https://api.resumatorapi.com/v1/applicants?apikey= xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
If you have more than 100 applicants, you need to request data by page number like this for page 1:
https://api.resumatorapi.com/v1/applicants/page/1</?apikey= xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The URI above returns the same 100 applicants as the URI without a page number. To get applicants 101 through 199, you use this URI:
https://api.resumatorapi.com/v1/applicants/page/2?apikey= xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The API request returns only an array of JSON documents. There is no metadata that you can use to determine how many pages are available. You just keep trying a new page number until no more data is returned. Therefore, our solution has to be able to dynamically generate some number of URIs for which we’ll arbitrarily set a limit.
Get a Static Page First
I like to approach development incrementally to make it easier to troubleshoot. In this case, I started by setting up a hard-coded reference to the URI for page 1. I created a new Web data source using the applicable URI. The results are returned as a list, so I used the To Table transform to get a table of records, and then I expanded the records so that my table includes all the fields from the JSON documents. When finished, the M script for this operations looks like this:
let Source = Json.Document(Web.Contents("https://api.resumatorapi.com/v1/applicants/page/1?apikey= xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")), #"Converted to Table" = Table.FromList(Source, Splitter.SplitByNothing(), null, null, ExtraValues.Error), #"Expanded Column1" = Table.ExpandRecordColumn(#"Converted to Table", "Column1", {"id", "first_name", "last_name", "prospect_phone", "apply_date", "job_id", "job_title"}, {"id", "first_name", "last_name", "prospect_phone", "apply_date", "job_id", "job_title"}) in #"Expanded Column1"
Get a Set of Pages
Now I need to not only make this URI dynamic so that I can programmatically change page numbers, but I also need to make sure that all of the JSON documents from each page are combined into a single result set. Matt Masson (b | t) describes how to do this in his post, Iterating over multiple pages of web data using Power Query.
So I dutifully convert my query to a function (by editing in the Advanced Editor) like this:
(page as number) as table => let Source = Json.Document(Web.Contents("https://api.resumatorapi.com/v1/applicants/page/" & Number.ToText(page) & "?apikey= xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")), #"Converted to Table" = Table.FromList(Source, Splitter.SplitByNothing(), null, null, ExtraValues.Error), #"Expanded Column1" = Table.ExpandRecordColumn(#"Converted to Table", "Column1", {"id", "first_name", "last_name", "prospect_phone", "apply_date", "job_id", "job_title"}, {"id", "first_name", "last_name", "prospect_phone", "apply_date", "job_id", "job_title"}) in #"Expanded Column1"
In this code, I’m setting up page as my parameter and then using it in the Web.Contents argument by concatenating it (after converting it to a string) in between the static URL and the apikey query parameter. Then I rename the query to getPage.
Next, I need to use the function to loop through a series of pages. For simplicity, I’m going to set up a loop for 3 pages. (In production, we set a much higher number. There’s no way to anticipate this value, so we’ll have to periodically review the data and adjust the upper limit as needed.)
To do this, I’ll add a blank query, open the Advanced Editor, and add in the following code:
let Source = {1..3}, #"Converted to Table" = Table.FromList(Source, Splitter.SplitByNothing(), null, null, ExtraValues.Error), #"Renamed Columns" = Table.RenameColumns(#"Converted to Table",{{"Column1", "Page"}}), #"Added Custom" = Table.AddColumn(#"Renamed Columns", "Custom", each <strong>getPage([Page])</strong>), #"Expanded Custom" = Table.ExpandTableColumn(#"Added Custom", "Custom", {"id", "first_name", "last_name", "prospect_phone", "apply_date", "job_id", "job_title"}, {"id", "first_name", "last_name", "prospect_phone", "apply_date", "job_id", "job_title"}) in #"Expanded Custom"
The initial line containing Source sets up a list containing 3 values: 1, 2, and 3. The next line converts this list to a table, and then the single column it contains is renamed from Column1 to Page.
Notice the line containing #”Added Custom”. Here’s where a custom column gets added that calls the getPage function and passes in the value in the Page column. In other words, on the first row, the Page column contains 1, so the 1 gets passed into the function which returns a set of rows for the converted JSON documents from page 1. These get added to the table in the new query.
Then the second row passes in a 2, the function is invoked, and gets another set of documents which are converted into rows. Now my table contains 200 rows of data. The final row of the converted-from-a-list-of-3 table gets the final 100 rows of data so that I know have a total of 300 rows in my new query.
Data Source Unsupported for Refresh
I can adjust the list to get any number of pages that I want and it just works. In Power BI Desktop, that is. When I publish to the Power BI service, and try to refresh the data, I get the following error:
“You can’t schedule refresh for this dataset because one or more sources currently don’t support refresh.”
Grr… This defeats the whole purpose of getting pages of data dynamically! And so this is where the quest for another solution began, and the adaptation of Chris Webb’s approach to dynamic URLs.
“Faking Out” Web.Contents
As Chris explains in his post, the problem is that the Power BI service wants to validate the URI before it commits to refreshing the data source. Because the URI isn’t static, there is no URI to validate.
If the page number were a query parameter, as it is in many REST APIs, then the approach that Chris describes works beautifully. I was stumped for a bit because I got hung up on the notion that the base URL should be the part that’s static – https://api.resumatorapi.com/v1. I was thinking that the applicants portion was really treated as a parameter by the JazzHR API just like page number. My thought process for the solution was influenced by another post by Chris, Using The RelativePath And Query Options with Web.Contents() In Power Query And Power BI M Code.
I decided to set up the static URI with the applicants included, and then set up my dynamic page numbering in the RelativePath argument. I changed the Source line in the getPage function to this:
Source = Json.Document(Web.Contents("https://api.resumatorapi.com/v1/applicants?apikey= xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", [RelativePath="page/" & Number.ToText(page), Query=[apikey=" xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]]))
Success!
This technique highlights the behavior of the RelativePath and Query options. RelativePath appends to the string that appears before the ? in the URI and Query replaces whatever you have set as the query parameter in the static URI.
I published to the Power BI service and was successfully able to refresh. The only “issue” was that I had to edit the credentials – which were anonymous. But all this entailed was opening the dialog box for the data set credentials and hitting OK.
Hope this helps should you find yourself wrangling with REST APIs that require dynamic values.
47 Comments
Very helpful. I’ve been using Microsoft Flow instead of Power Query to access Rest based APIs for the following reason:
1. If the API is documented via Swagger then you can create a custom connector that’s fully documented and easy to use in the visual (SSIS like) environment for all API access
2. I can load the data into SQL Server. I prefer loading the raw JSON as a JSON data type since I feel comfortable in SQL but you can easily shred the json in Flow and have nice drag-and-drop columns
3. If JazzHR supports webhooks then you can do scenarios such as when an applicant record is created JazzHR calls the flow webhook URL which trigger the flow to run with the JSON payload and you can then instantly add to the database, power bi, flat file or whatever in real time
Thanks for the tips, Suhail!
How can you easily shred the json in Flow?
Can you please share any link?
Thanks
Hi,
I’m having a little difficulty making this work. Here’s the URI I’m using to pull back my data:
https://api.harvestapp.com/v2/time_entries?access_token=****&account_id=****
If I want to bring back a specific page my URIL looks as follows:
https://api.harvestapp.com/v2/time_entries?page=10&access_token=****&account_id=****
When I use your code above it replaces the ‘?’ with ‘/’ then introduces ‘page/’ after it, which doesn’t work with my URI:
https://api.harvestapp.com/v2/time_entries/page=1?access_token=****&account_id=****
I don’t have an API Key, so I’ve been adding in my access_token here instead. Would this also be an issue?
Can you tell me where I’m going wrong?
Thanks
Hi Mark,
Without seeing your code, I can’t say for certain what is wrong. But here’s what I would try:
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?page=” & Number.ToText(page) & “&access_token=****&account_id=****”,
[Query=[page=” & Number.ToText(page) & “&access_token=****&account_id=****”]]))
You wouldn’t need relative path in this case because your page value is treated as a query parameter in the URL and not as part of the path as I showed in my example. Please do let me know if my proposed solution works for you.
Hi – looking for some help. I think I am close but when applying the following code I receive an error when adding the custom column in the table query: “Unexpected error: Operation is not valid due to the current state of the object.”
Code:
getPage:
(page as text) =>
let
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=*****&account_id=*****&page=”&(page),
[Query=[page=(page)]])),
#”Converted to Table” = Record.ToTable(Source),
Value = #”Converted to Table”{0}[Value],
#”Converted to Table1″ = Table.FromList(Value, Splitter.SplitByNothing(), null, null, ExtraValues.Error)
in
#”Converted to Table1″
table query:
let
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=*****&account_id=*****”)),
List = {1..Source[total_pages]},
#”Converted to Table” = Table.FromList(List, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Renamed Columns” = Table.RenameColumns(#”Converted to Table”,{{“Column1”, “Page”}}),
#”Changed Type” = Table.TransformColumnTypes(#”Renamed Columns”,{{“Page”, type text}}),
#”Added Custom” = Table.AddColumn(#”Changed Type”, “Custom”, each getPage([Page]))
in
#”Added Custom”
Thank you for your help in advance.
The problem appears to be related to calling the function. I set up a Harvest account as a free trial so that I could try to reproduce your error. I never got to your error, but I did have to change code in your getPage function. Change the second line to
Query=[page=(page)],
Hopefully that’s all you need to make it work.
Thanks Stacia. Can you paste your full getPage function? I’ve made the change your are suggesting, but can’t seem to get it to work.
Thank you!
getPage:
(page as text) =>
let
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=*****&account_id=*****&page=”&(page),
Query=[page=(page)],
#”Converted to Table” = Record.ToTable(Source),
Value = #”Converted to Table”{0}[Value],
#”Converted to Table1″ = Table.FromList(Value, Splitter.SplitByNothing(), null, null, ExtraValues.Error)
in
#”Converted to Table1″
Here’s my version:
(page as text) =>
let
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=***&account_id=***”)),
Query=[page=(page)],
#”Converted to Table” = Record.ToTable(Source),
Value = #”Converted to Table”{0}[Value],
#”Converted to Table1″ = Table.FromList(Value, Splitter.SplitByNothing(), null, null, ExtraValues.Error) in
#”Converted to Table1″
It looks the same…. What do you mean by “can’t seem to get it to work”?
Here’s also my usage of the function – not as cleaned up as I would normally do, but my goal here was to prove the function worked and I can see data:
let
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=***&account_id=***”)),
List = {1..Source[total_pages]},
#”Converted to Table” = Table.FromList(List, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Renamed Columns” = Table.RenameColumns(#”Converted to Table”,{{“Column1”, “Page”}}),
#”Changed Type” = Table.TransformColumnTypes(#”Renamed Columns”,{{“Page”, type text}}),
#”Added Custom” = Table.AddColumn(#”Changed Type”, “Custom”, each getPage([Page])),
#”Expanded Custom” = Table.ExpandTableColumn(#”Added Custom”, “Custom”, {“Column1”}, {“Custom.Column1″}),
#”Expanded Custom.Column1″ = Table.ExpandRecordColumn(#”Expanded Custom”, “Custom.Column1”, {“id”, “spent_date”, “hours”, “notes”, “is_locked”, “locked_reason”, “is_closed”, “is_billed”, “timer_started_at”, “started_time”, “ended_time”, “is_running”, “billable”, “budgeted”, “billable_rate”, “cost_rate”, “created_at”, “updated_at”, “user”, “client”, “project”, “task”, “user_assignment”, “task_assignment”, “invoice”, “external_reference”}, {“Custom.Column1.id”, “Custom.Column1.spent_date”, “Custom.Column1.hours”, “Custom.Column1.notes”, “Custom.Column1.is_locked”, “Custom.Column1.locked_reason”, “Custom.Column1.is_closed”, “Custom.Column1.is_billed”, “Custom.Column1.timer_started_at”, “Custom.Column1.started_time”, “Custom.Column1.ended_time”, “Custom.Column1.is_running”, “Custom.Column1.billable”, “Custom.Column1.budgeted”, “Custom.Column1.billable_rate”, “Custom.Column1.cost_rate”, “Custom.Column1.created_at”, “Custom.Column1.updated_at”, “Custom.Column1.user”, “Custom.Column1.client”, “Custom.Column1.project”, “Custom.Column1.task”, “Custom.Column1.user_assignment”, “Custom.Column1.task_assignment”, “Custom.Column1.invoice”, “Custom.Column1.external_reference”}) in
#”Expanded Custom.Column1″
Hi Stacia, is it possible to share the test PBI file you created to test the Harvest API connection above?
Here’s a link to the file – with the access token and account id masked. UPDATE: I replaced the file with a new version at 15:25 Pacific time on 24 Apr 2018 based on the exchange below with Francis.
This file contains an example of calling the Harvest API. The access token and account ID have been masked. There’s no visualization in the report. This file was created solely to test a query and a function that calls the Harvest API. Documentation for the API is available at https://help.getharvest.com/api-v2/
[…] these links. Fortunately, I’ve dealt with this type of problem before. It’s just a variation on a technique I described recently regarding dynamic JSON […]
Hi Stacia – I don’t have the option to reply to the thread above. So starting a new one.
I tried your solution, the problem is that the URL is not specifying the page. So the URL in your code only pulls back page 1 every time, no matter how many pages there are (I have 186).
Your Source URL:
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=***&account_id=***”)),
Query=[page=(page)]
What I think it needs to be:
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=***&account_id=***&page=”&(page))),
Query=[page=(page)],
Can you try that in your .pbix file and let me know if it works? I am getting: “Unexpected error: Operation is not valid due to the current state of the object.”
Thanks again for the help! I feel like we are very close!
I don’t have more than 1 page – I just set up a trial account and it would be very time consuming for me to enter in all the data, but I’m fairly certain we don’t need the &page= portion in the Web.Contents call based on another review of Chris Webb’s discussion of this topic that I cited in my post. But I do see what I think is the problem now – and that is the Query=[page=(page)] needs to be the second argument in the Web.Contents call. I rearranged the function- and I get results if I query for page 1 and no results (correctly) if I query for page 2, but again I don’t have more than 1 page so hard to test. Probably faster for you to try this:
(page as text) =>
let
Source = Json.Document(Web.Contents(“https://api.harvestapp.com/v2/time_entries?access_token=***&account_id=***”,
[Query=[page=(page)]])),
#”Converted to Table” = Record.ToTable(Source),
Value = #”Converted to Table”{0}[Value],
#”Converted to Table1″ = Table.FromList(Value, Splitter.SplitByNothing(), null, null, ExtraValues.Error) in
#”Converted to Table1″
That worked! Thank you so much!
Yay! 🙂
[…] explained how to solve this particular problem once upon a time. For the hockey data queries, I had to use variations on that theme to get the dynamic URLs to […]
Stacia
thanks for taking out time to help us…It took me several days just to make a GET call through Power BI. Thanks to all folks like you finally I made the call and realized that it brings only 30 records..which means I need the calls to continue until I can reach the end of output or atleast to test 10 pages may be
Documentation shows that each call will return 30 records. This is the documentation I found on the site:
Append the parameter “page=[:page_no]” in the url to traverse through pages.
So I tried this
(page as number) as table =>
let
Source = Json.Document(Web.Contents(“https://swinerton.freshservice.com/helpdesk/tickets/filter/all_tickets/page/ ” & Number.ToText(page), [Headers=[Authorization=”Basic xxxxxxxxxxxxxxxx”, #”Content_Type”=”application/json”]])),
#”Converted to Table” = Table.FromList(Source, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Expanded Column1″ = Table.ExpandRecordColumn(#”Converted to Table”, “Column1”, {“cc_email”, “created_at”, “deleted”, “department_id_value”, “display_id”, “due_by”, “email_config_id”, “frDueBy”, “fr_escalated”, “group_id”, “id”, “isescalated”, “owner_id”, “priority”, “requester_id”, “responder_id”, “source”, “spam”, “status”, “subject”, “ticket_type”, “to_email”, “updated_at”, “description”, “description_html”, “status_name”, “requester_status_name”, “priority_name”, “source_name”, “requester_name”, “responder_name”, “to_emails”, “department_name”, “assoc_problem_id”, “assoc_change_id”, “assoc_change_cause_id”, “assoc_asset_id”, “custom_field”}, {“Column1.cc_email”, “Column1.created_at”, “Column1.deleted”, “Column1.department_id_value”, “Column1.display_id”, “Column1.due_by”, “Column1.email_config_id”, “Column1.frDueBy”, “Column1.fr_escalated”, “Column1.group_id”, “Column1.id”, “Column1.isescalated”, “Column1.owner_id”, “Column1.priority”, “Column1.requester_id”, “Column1.responder_id”, “Column1.source”, “Column1.spam”, “Column1.status”, “Column1.subject”, “Column1.ticket_type”, “Column1.to_email”, “Column1.updated_at”, “Column1.description”, “Column1.description_html”, “Column1.status_name”, “Column1.requester_status_name”, “Column1.priority_name”, “Column1.source_name”, “Column1.requester_name”, “Column1.responder_name”, “Column1.to_emails”, “Column1.department_name”, “Column1.assoc_problem_id”, “Column1.assoc_change_id”, “Column1.assoc_change_cause_id”, “Column1.assoc_asset_id”, “Column1.custom_field”}),
#”Expanded Column1.cc_email” = Table.ExpandRecordColumn(#”Expanded Column1″, “Column1.cc_email”, {“cc_emails”, “fwd_emails”, “reply_cc”, “tkt_cc”}, {“Column1.cc_email.cc_emails”, “Column1.cc_email.fwd_emails”, “Column1.cc_email.reply_cc”, “Column1.cc_email.tkt_cc”}),
#”Expanded Column1.custom_field” = Table.ExpandRecordColumn(#”Expanded Column1.cc_email”, “Column1.custom_field”, {“level_2_12133”, “level_3_12133”, “preferred_method_of_contact_12133”, “best_time_to_contact_12133”, “phone_number_12133”, “level_1_12133”, “office_location_12133”, “job_number_12133”}, {“Column1.custom_field.level_2_12133”, “Column1.custom_field.level_3_12133”, “Column1.custom_field.preferred_method_of_contact_12133”, “Column1.custom_field.best_time_to_contact_12133”, “Column1.custom_field.phone_number_12133”, “Column1.custom_field.level_1_12133”, “Column1.custom_field.office_location_12133”, “Column1.custom_field.job_number_12133″}),
#”Expanded Column1.cc_email.cc_emails” = Table.ExpandListColumn(#”Expanded Column1.custom_field”, “Column1.cc_email.cc_emails”),
#”Expanded Column1.cc_email.fwd_emails” = Table.ExpandListColumn(#”Expanded Column1.cc_email.cc_emails”, “Column1.cc_email.fwd_emails”),
#”Expanded Column1.cc_email.reply_cc” = Table.ExpandListColumn(#”Expanded Column1.cc_email.fwd_emails”, “Column1.cc_email.reply_cc”),
#”Expanded Column1.cc_email.tkt_cc” = Table.ExpandListColumn(#”Expanded Column1.cc_email.reply_cc”, “Column1.cc_email.tkt_cc”)
in
#”Expanded Column1.cc_email.tkt_cc”
It throws no syntax error
Next I created the other query like you asked
let
Source = {1..3},
#”Converted to Table” = Table.FromList(Source, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Renamed Columns” = Table.RenameColumns(#”Converted to Table”,{{“Column1”, “Page”}}),
#”Added Custom” = Table.AddColumn(#”Renamed Columns”, “Custom”, each getPage([Page])),
#”Expanded Column1″ = Table.ExpandRecordColumn(#”Converted to Table”, “Column1”, {“cc_email”, “created_at”, “deleted”, “department_id_value”, “display_id”, “due_by”, “email_config_id”, “frDueBy”, “fr_escalated”, “group_id”, “id”, “isescalated”, “owner_id”, “priority”, “requester_id”, “responder_id”, “source”, “spam”, “status”, “subject”, “ticket_type”, “to_email”, “updated_at”, “description”, “description_html”, “status_name”, “requester_status_name”, “priority_name”, “source_name”, “requester_name”, “responder_name”, “to_emails”, “department_name”, “assoc_problem_id”, “assoc_change_id”, “assoc_change_cause_id”, “assoc_asset_id”, “custom_field”}, {“Column1.cc_email”, “Column1.created_at”, “Column1.deleted”, “Column1.department_id_value”, “Column1.display_id”, “Column1.due_by”, “Column1.email_config_id”, “Column1.frDueBy”, “Column1.fr_escalated”, “Column1.group_id”, “Column1.id”, “Column1.isescalated”, “Column1.owner_id”, “Column1.priority”, “Column1.requester_id”, “Column1.responder_id”, “Column1.source”, “Column1.spam”, “Column1.status”, “Column1.subject”, “Column1.ticket_type”, “Column1.to_email”, “Column1.updated_at”, “Column1.description”, “Column1.description_html”, “Column1.status_name”, “Column1.requester_status_name”, “Column1.priority_name”, “Column1.source_name”, “Column1.requester_name”, “Column1.responder_name”, “Column1.to_emails”, “Column1.department_name”, “Column1.assoc_problem_id”, “Column1.assoc_change_id”, “Column1.assoc_change_cause_id”, “Column1.assoc_asset_id”, “Column1.custom_field”}),
#”Expanded Column1.cc_email” = Table.ExpandRecordColumn(#”Expanded Column1″, “Column1.cc_email”, {“cc_emails”, “fwd_emails”, “reply_cc”, “tkt_cc”}, {“Column1.cc_email.cc_emails”, “Column1.cc_email.fwd_emails”, “Column1.cc_email.reply_cc”, “Column1.cc_email.tkt_cc”}),
#”Expanded Column1.custom_field” = Table.ExpandRecordColumn(#”Expanded Column1.cc_email”, “Column1.custom_field”, {“level_2_12133”, “level_3_12133”, “preferred_method_of_contact_12133”, “best_time_to_contact_12133”, “phone_number_12133”, “level_1_12133”, “office_location_12133”, “job_number_12133”}, {“Column1.custom_field.level_2_12133”, “Column1.custom_field.level_3_12133”, “Column1.custom_field.preferred_method_of_contact_12133”, “Column1.custom_field.best_time_to_contact_12133”, “Column1.custom_field.phone_number_12133”, “Column1.custom_field.level_1_12133”, “Column1.custom_field.office_location_12133”, “Column1.custom_field.job_number_12133″}),
#”Expanded Column1.cc_email.cc_emails” = Table.ExpandListColumn(#”Expanded Column1.custom_field”, “Column1.cc_email.cc_emails”),
#”Expanded Column1.cc_email.fwd_emails” = Table.ExpandListColumn(#”Expanded Column1.cc_email.cc_emails”, “Column1.cc_email.fwd_emails”),
#”Expanded Column1.cc_email.reply_cc” = Table.ExpandListColumn(#”Expanded Column1.cc_email.fwd_emails”, “Column1.cc_email.reply_cc”),
#”Expanded Column1.cc_email.tkt_cc” = Table.ExpandListColumn(#”Expanded Column1.cc_email.reply_cc”, “Column1.cc_email.tkt_cc”)
in
#”Expanded Column1.cc_email.tkt_cc”
It keeps pointing to error on Added custom row
Please I have been struggling for several weeks now to resolve this…I appreciate all your assistance
Swati
See my reply over here: http://blog.datainspirations.com/2018/05/12/revisiting-dynamic-web-contents/
Stacia
When I invoke the query I see this error
An error occurred in the ‘’ query. Expression.Error: We cannot apply operator & to types Text and Number.
Details:
Operator=&
Left=https://swinerton.freshservice.com/helpdesk/tickets/filter/all_tickets.json
Right=3
Hey Stacia thanks for the blog. I have a similar with refresh not working…Only difference is the API has the meta data for the next page URL so i can see the last page has a null for next page.
Here is a link to api info http://developer.myob.com/api/accountright/v2/
How should i modify this code to use the refresh in webservice?
let
BaseUrl = “http://localhost:8080/AccountRight/bba0f8af-30f4-4dc1-a970-2aa81f685506/GeneralLedger/JournalTransaction/?api-version=v2&$top=1000”,
EntitiesPerPage = 1000,
GetJson = (Url) =>
let RawData = Web.Contents(Url),
Json = Json.Document(RawData)
in Json,
GetTotalEntities = () =>
let Json = Json.Document(Web.Contents(“http://localhost:8080/AccountRight/bba0f8af-30f4-4dc1-a970-2aa81f685506/GeneralLedger/JournalTransaction/?api-version=v2”)),
Items = Json[Count]
in Items,
GetPage = (Index) =>
let skip = “$skip=” & Text.From(Index * EntitiesPerPage),
Url = BaseUrl & “&” & skip
in Url,
EntityCount = List.Max({EntitiesPerPage, GetTotalEntities()}),
PageCount = Number.RoundUp(EntityCount / EntitiesPerPage),
PageIndices = {0 .. PageCount – 1},
URLs = List.Transform(PageIndices, each GetPage(_)),
Pages = List.Transform(URLs, each GetJson(_)),
ToTable = Table.FromList(Pages, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ExpandTable = Table.ExpandRecordColumn(ToTable, “Column1”, {“Items”}, {“Column1.Items”}),
ExpandRecords = Table.ExpandListColumn(ExpandTable, “Column1.Items”),
TableData = Table.ExpandRecordColumn(ExpandRecords, “Column1.Items”, {“UID”, “DisplayID”, “JournalType”, “SourceTransaction”, “DateOccurred”, “DatePosted”, “Description”, “Lines”, “URI”, “RowVersion”}, {“UID”, “DisplayID”, “JournalType”, “SourceTransaction”, “DateOccurred”, “DatePosted”, “Description”, “Lines”, “URI”, “RowVersion”})
in
TableData
It’s really hard to make a recommendation without being able to test the code myself. The key is that where you make the WebContents call, you have to provide a URL that resolves correctly. My first guess would be that you need to consolidate the GetJson and GetPage to do something like this:
GetPage = (Index) =>
let
skipValue = Text.From(Index * EntitiesPerPage),
UrlTemplate = “http://localhost:8080/AccountRight/bba0f8af-30f4-4dc1-a970-2aa81f685506/GeneralLedger/JournalTransaction/?api-version=v2&$top=1000$skip=1000”,
RawData = Web.Contents(UrlTemplate, [Query=[$top=1000, $skip=skipValue]])),
Json = Json.Document(RawData)
in Json
The reason is that you need to provide a URL that resolves properly with a hard-coded set of query parameters. Then you can use the Query argument of Web.Contents to pass in the parameter names and values. In your example, only the $skip parameter changes value based on the value passed into the function.
You would also need to change this section:
URLs = List.Transform(PageIndices, each GetPage(_)),
Pages = List.Transform(URLs, each GetJson(_)),
ToTable = Table.FromList(Pages, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
To this:
Pages = List.Transform(PageIndices, each GetPage(_)),
ToTable = Table.FromList(Pages, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
You might have to fiddle a bit with this code as I can’t test it myself, but hopefully this gives you enough of a clue to get your code working and refreshable in the Power BI service.
Hey Stacia, thanks for your reply i will give this a go and see how it goes with refreshing in the service. thanks again for the pointers.
Hello everyone, I have read the entire blog and it completely covers each and every aspect of Power BI. I myself being a techie likes to read such blog posts and I admit that it is one of the best articles on Power BI which actually is a crucial business analytic tool from Microsoft.I don’t intend to question the blogger’s knowledge but I am just have something to share from my side:
https://zappysys.com/blog/howto-import-json-rest-api-power-bi/
Hey Stacia,
Just wanted to send a big thank – I was doing alternate ways to bypass this scenario. finally have been able to loop through multiple calls – Great help. thanks so much for great work .
Hi Stacia,
I know this is an old post, but I am struggling to get my recursive code working after following your blog and trying many different combinations. Basically I need to provide authentication (basic) each call. I have trued adding the Authorization header too, but still same issue.
In this example using a fake API, I do 5 calls and need to authenticate 5 times. Do you have any ideas?
Thanks
Tony
// First call is: https://www.myFakeAPI.com/APIService/v6/feed/Engine/
// Returns data table and next URL e.g. https://www.myFakeAPI.com/APIService/v6/feed/Engine/2374856
// Next call is: https://www.myFakeAPI.com/APIService/v6/feed/Engine/2374856
// and repeat
let
iterations = 5, /* How many times to iterate – will increase to suit */
urladdress = “https://www.myFakeAPI.com/APIService/v6/feed/Engine/”,
startquery = “”, /* Can be blank or 0 */
FnGetOnePage =
(passedPath) as record =>
let
Source = Xml.Tables(Web.Contents(urladdress,[RelativePath=passedPath])),
Table0 = Source{0}[Table],
next = try Table0{0}[Table][url] otherwise null,
data = try Table0{1}[Table] otherwise null,
res = [Data=data, Next=next]
in
res,
GeneratedList =
List.Generate(
()=>[i=0, res = FnGetOnePage(startquery)],
each [i]<iterations and [res][data]null,
each [i=[i]+1, res = FnGetOnePage(Replacer.ReplaceText([res][Next]{0},urladdress,””))],
each [res][data])
in
GeneratedList
That really saved me (and a client).
Thank you for posting!
Happy to help!
Excelente.!!!
Soluciono mi problema, el secreto es no pasar el url con una variable
———————————————————————
————-Ejemplo: Forma erronea————————
———————————————————————
url = “https://www.test-test.com/”,
FnGetOnePage =
(passedPath) as record =>
let
Source = Xml.Tables(Web.Contents(url,[RelativePath=passedPath])),
———————————————————————
————-Ejemplo: Forma de correcta——————-
———————————————————————
url = “https://www.test-test.com/”,
FnGetOnePage =
(passedPath) as record =>
let
Source = Xml.Tables(Web.Contents(“https://www.test-test.com/”,[RelativePath=passedPath])),
Con este pequeño cambio soluciono la actualizacion automatica
I have a API URL which works like this :
https://jira.company.com/rest/api/2/issue/RC-2345/worklog
to get the data from JIRA , the param is RC-2345 in this rest is static , how can we create a similar funtion to pass the JIRAID as param , i tried the below sample function but it doesnt pass it correctly.
= (JIRAID as text) =>
let Source = Json.Document(Web.Contents(“https://jira.company.com/rest/api/2/issue”, [Query=[JIRAID]], [RelativePath=”worklog/”]))
in Source
The Query option in the Web.Contents function requires that you have something that looks like this in the URL: https://url?query=queryargument. Can you pass the JIRAID into the RelativePath instead? I can’t test the URL, but I would try this for RelativePath:
[RelativePath=JIRAID & “/worklog/”]
Thank you so much!
This was really really helpful.
– “The only “issue” was that I had to edit the credentials – which were anonymous. But all this entailed was opening the dialog box for the data set credentials and hitting OK.”
I’m having this issue right now, but I can’t get around it just hitting OK. I have the following error:
“Failed to update data source credentials: The credentials provided for the Web source are invalid. ”
Do you have an idea of how to solve this ?
Thanks.
I believe the solution depends on what your data source is. I don’t have enough information from your comment to suggest a fix.
Dear Stacia,
It seem that i only get NULL in all fields. Do you know what i do wrong ?
GetData:
(page as number) as table =>
let
Source = Json.Document(Web.Contents(“https://api.recman.no/v2/get/?key=xxxxxxxxxxx&scope=project&fields=name, companyId&page=” & Number.ToText(page))),
data = Source[data],
#”Converted to Table” = Record.ToTable(data),
#”Expanded Column1″ = Table.ExpandRecordColumn(#”Converted to Table”, “Value”, {“projectId”, “name”, “companyId”}, {“Value.projectId”, “Value.name”, “Value.companyId”})
in
#”Expanded Column1″
Query1:
let
Source = {1..2},
#”Converted to Table” = Table.FromList(Source, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Renamed Columns” = Table.RenameColumns(#”Converted to Table”,{{“Column1”, “Page”}}),
#”Added Custom” = Table.AddColumn(#”Renamed Columns”, “Custom”, each GetPage([Page])),
#”Expanded Custom” = Table.ExpandTableColumn(#”Added Custom”, “Custom”, {“value”, “projectId”, “name”, “companyId”}, {“Value.value”, “Value.projectId”, “Value.name”, “Value.companyId”})
in
#”Expanded Custom”
Hi Asgaut,
It’s really hard for me to say when I can’t test the code myself. I don’t have access to this API so I can’t test it. Generally my approach to this type of problem is to try the steps in the code that ultimately becomes the custom function as a standard query with a hard-coded value, such as 1 for page, like this:
https://api.recman.no/v2/get/?key=xxxxxxxxxxx&scope=project&fields=name, companyId&page=1
Does that return a result? If that works, then all I can suggest is that you incrementally add one line of code as you work through hard-coded values to ensure you get the results you expect at each line. Ultimately, you should be able to zero in on the line that is causing the problem. I’m sorry I couldn’t be more help.
Thank you so much for this blog. It has really saved me from a tricky problem. Absolutely fantastic.
Happy to help!
Have been puzzling over this for a couple days until I found your entry. Thanks!
Hoping that MS will add the ability to set the Web.Contents arguments in the Desktop UI sometime…
Hi Stacia
This is my code that works in Power BI Desktop but does not allow refresh in Power BI Service.
= (page as number) as table =>
let
Source = Json.Document(Web.Contents(“https://***.t1cloud.com/T1Default/CiAnywhere/Web/***/Api/RaaS/v1/CategorySpend?pageSize=100&page=” & Number.ToText(page) &””, [Headers=[Authorization=”Basic *****TUFkbSF*****”]])),
DataSet = Source[DataSet],
#”Converted to Table” = Table.FromList(DataSet, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Expanded Column1″ = Table.ExpandRecordColumn(#”Converted to Table”, “Column1”, {“INVOICE_NUMBER”, “INVOICE_TYPE”, “INVOICE_STATUS”, “PROCESSING_GROUP_NAME”, “POSTED_STATUS”, “LINE_DESCRIPTION”, “LINE_LONG_DESCRIPTION”, “LINE_COMMENT”, “PRODUCT”, “CATALOGUE_NAME”, “PRODUCT_NUMBER”, “PROD_DESCR”, “FINAL_INVOICE_IND”, “DOCUMENT_NUMBER”, “BAT_NAME”, “INVOICE_LNE_NBR”, “SUPPLIER_NAME”, “SUPPLIER_ACCOUNT”, “SUPPLIER_ABN”, “LOCATION”, “SUB_LOCATION”, “INVOICE_AMT_INC”, “INVOICE_AMT_EX”, “ADJ_INVOICE_AMT_INC”, “ADJ_INVOICE_AMT_EX”, “DOC_DATE”, “PERIOD”, “YEAR”, “GROUP_CODE”, “GROUP_DESCRIPTION”, “PERIOD_DESCR”, “PO_NUMBER”}, {“INVOICE_NUMBER”, “INVOICE_TYPE”, “INVOICE_STATUS”, “PROCESSING_GROUP_NAME”, “POSTED_STATUS”, “LINE_DESCRIPTION”, “LINE_LONG_DESCRIPTION”, “LINE_COMMENT”, “PRODUCT”, “CATALOGUE_NAME”, “PRODUCT_NUMBER”, “PROD_DESCR”, “FINAL_INVOICE_IND”, “DOCUMENT_NUMBER”, “BAT_NAME”, “INVOICE_LNE_NBR”, “SUPPLIER_NAME”, “SUPPLIER_ACCOUNT”, “SUPPLIER_ABN”, “LOCATION”, “SUB_LOCATION”, “INVOICE_AMT_INC”, “INVOICE_AMT_EX”, “ADJ_INVOICE_AMT_INC”, “ADJ_INVOICE_AMT_EX”, “DOC_DATE”, “PERIOD”, “YEAR”, “GROUP_CODE”, “GROUP_DESCRIPTION”, “PERIOD_DESCR”, “PO_NUMBER”})
in
#”Expanded Column1″
When I try to “Fake Out” the web content per above I keep getting the error:
“Expression.SyntaxError: Token Comma expected”
I have tried both with the RelativePath and with just the Query=.
Any ideas?
I am connecting to api and i am getting an issue
I am not sure about what happened since I used an api before and it is not returning this issue. I also setup a scheduled refresh before.
Here is my code below (App ID and key are parameterized):
enddate = Date.ToText(Date.AddDays(Date.From(DateTime.FixedLocalNow()),-1), “yyyy-MM-dd”),
url = “https://sampleapi.net/api/metrics/”,
query = “/dataTable?startDate=2020-03-01&endDate=” & enddate & “&userText=Text”,
Source = Json.Document(Web.Contents(url & AppID & query, [Headers=[#”x-api-key”=AppKey]]))
I tried using variables for other components of the url but it still didn’t work.
I wanna try the relative path but it still wouldn’t work, I don’t know what I’m doing wrong.
Hi All,
this was really an useful blog for someone like me who is new to these technologies. I am facing same issue with refresh the Power BI report in Power BI service even when it’s working fine in Power BI Desktop. My query is totally dynamic and trying to fetch data from multiple servers. It’s fetching different beginning part from an excel sheet and appending to tail. In M query the code is like
Source = Json.Document(Web.Contents( url & “/ProcessData/AtProcessDataREST.dll/SQL?”))
Where the url is coming from a column in excel saved in MS Teams and have multiple rows like http://server.service.domain.com
Can anyone help please.
Regards,
Ayan
Note: I (Stacia) removed the actual URL here to protect the security of your server environment.
Hi Stacia,
I see great discussions all over this post . I am facing a similar issue of refreshing Dynamic data source in Power BI service. Below is how my code looks like :
let
url =
“https://addm-dc.lowes.com/api/v1.1/data/search?format=object&query=SEARCH%20SoftwareInstance%20WHERE%20excluded%20SHOW%20type%20PROCESSWITH%20unique(0)”,
FnGetOnePage =
(url) as record =>
let
Source = Json.Document(Web.Contents(url,[Headers=[Authorization=”Bearer xyz”]])),
data = try Source{0}[results] otherwise null,
next = try Source{0}[next] otherwise null,
res = [Data=data, Next=next]
in
res,
CombinedList=
List.Combine(
List.Generate(
()=>[res = FnGetOnePage(url)],
each [res][Data]null,
each [next_url= [res][Next], res = FnGetOnePage(next_url)],
each [res][Data])),
#”Converted to Table” = Table.FromList(CombinedList, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Expanded Column1″ = Table.ExpandRecordColumn(#”Converted to Table”, “Column1”, {“type”}, {“Column1.type”})
in
#”Expanded Column1″
In place of token authentication , I replaced key with xyz. I am trying to add Relative path to this Power Query. However, i am not successful so far. Please help me .
Hi Stacia ,
I am facing some issues to enable refresh schedule using the m query below. kindly assist .
let
url =
“https://addm-dc.lowes.com/api/v1.1/data/search?format=object&query=search%20Host%20with%20(traverse%20%3A%3A%3ASoftwareInstance%20as%20mw_sis%20where%20not%20excluded%20and%20%23Element%3AMaintainer%3APattern%3APattern.%23Pattern%3APatternModuleContainment%3APatternModule%3APatternModule.tree_path%20matches%20%27Application%20Server%20Middleware%27%20traverse%20ElementWithDetail%3ASupportDetail%3ASoftwareDetail%3ASupportDetail%20as%20support_detail)%20where%20nodecount(traverse%20%3A%3A%3AGroup%20where%20critical)%20%3E%200%20order%20by%20name%20show%20name%2C%20explode%20%23mw_sis.type%20%2C%20%23mw_sis.version%2C%20%23mw_sis.%23%3ASupportDetail%3ASoftwareDetail%3ASupportDetail.lowes_eos%2C%20explode%20replace(%23ContainedItem%3AContainment%3AContainer%3AGroup.name%2C%20%27StaticApp%3A%20%27%2C%20%27%27)%20as%20%27App%20Name%27%2C%20explode%20%23ContainedItem%3AContainment%3AContainer%3AGroup.app_id%20as%20%27App%20Id%27%0Aprocesswith%20show%20%405%20as%20%27App%20Id%27%2C%20%404%20as%20%27App%20Name%27%2C%20name%20as%20%27Server%20Name%27%2C%20%401%20AS%20%27Server%20Middleware%20Software%20Instance%27%2C%20%402%20AS%20%27Software%20Version%27%2C%20%403%20as%20%27End%20of%20Support%20Date%27″,
FnGetOnePage =
(url) as record =>
let
Source = Json.Document(Web.Contents(url,[Headers=[Authorization=”Bearer xyz”]])),
data = try Source{0}[results] otherwise null,
next = try Source{0}[next] otherwise null,
res = [Data=data, Next=next]
in
res,
CombinedList=
List.Combine(
List.Generate(
()=>[res = FnGetOnePage(url)],
each [res][Data]null,
each [next_url= [res][Next], res = FnGetOnePage(next_url)],
each [res][Data])),
#”Converted to Table” = Table.FromList(CombinedList, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
#”Expanded Column1″ = Table.ExpandRecordColumn(#”Converted to Table”, “Column1”, {“App Id”, “App Name”, “End of Support Date”, “Server Middleware Software Instance”, “Server Name”, “Software Version”}, {“App Id”, “App Name”, “End of Support Date”, “Server Middleware Software Instance”, “Server Name”, “Software Version”}),
#”Reordered Columns” = Table.ReorderColumns(#”Expanded Column1″,{“App Id”, “App Name”, “Server Name”, “Server Middleware Software Instance”, “Software Version”, “End of Support Date”}),
#”Changed Type” = Table.TransformColumnTypes(#”Reordered Columns”,{{“End of Support Date”, type date}}),
#”Removed Duplicates” = Table.Distinct(#”Changed Type”, {“App Name”, “Server Name”, “Server Middleware Software Instance”, “Software Version”})
in
#”Removed Duplicates”
I had to log in and share my thanks with you. I had built a whole bunch of web scrapers and was quite disappointed when Power BI Service refused to refresh any of them! This is a lifesaver.
Very glad that you were able to find a solution to your refresh issues!
Hi,
I have a small dataset of about 6 workbooks that are stored across various places in a large SharePoint site.
I can’t use a gateway and the Folder connector, because the file paths are too many characters, and I can’t move the files.
Using the SharePoint.Files connector works, but the report takes between 1-2hrs to refresh about 1,000 rows of data, and often fails due to api/contextinfo error.
Using SharePoint.Contents doesn’t help in this case, as the files are spread throughout too many different folder paths.
This got me thinking about Chris Webb’s Web.Contents RelativePath solution. I had read it, but never attempted to implement it.
While I can build a Dynamic Data Source that refreshes the SharePoint files in Desktop in about 1 minute using the Web.Contents approach, I can’t seem to take advantage of the RelativePath option because I can’t authenticate into the subsite.
my code:
Desktop Solution works a charm:
=Excel.Workbook(Web.Contents(Root&Path&File), null, true)
Root, Path, File are obviously Parameters, and then I use a function to call the correct details.
So, I attempted to modify this approach for the service:
=Excel.Workbook(Web.Contents(“MyCompany.SharePoint.com/sites/SubSite”, [RelativePath=Path&File]), null, true)
In theory I thought this should work, because if i type “MyCompany.SharePoint.com/sites/SubSite” into my browser, I can sign in and all is good.
BUT, in Power Query, I have to add a suffix to the URL before I can successfully authenticate:
“MyCompany.SharePoint.com/sites/SubSite/SitePages/Home(1).aspx”
This Suffix obviously messes with my ability to use RelativePath.
Do you know why I can’t authenticate into “MyCompany.SharePoint.com/sites/SubSite/” by itself?
Any ideas how I could overcome this?
Hello Stacia – I have made some progress using your technique. I have been able to publish and schedule anonymous refresh in the Power BI service. However, the call is not returning the results from each page and the number of pages in my function. The call returns the first page of 100 rows by the number of pages. So if it calls 5 pages it returns 500 rows, but it is only the first 100 rows 5 times so I have 100 distinct rows. I think it has to do with the authorization string prior to the pagination call.
Here is my API documentation and code in advanced editor for Power BI:
https://developers.greenhouse.io/harvest.html
This call works but cannot refresh in Power BI Service due to non-static URL.
(pagestart as text)=>
let
Source = Json.Document(Web.Contents(“https://harvest.greenhouse.io/v1/jobs?page=”&pagestart&”&per_page=500″, [Headers=[AUTHORIZATION=”Basic xxxxxxxxxxxxxx=”]])),
Modified your call but get 1 page of results (100 rows) x pagestart function rows.
(pagestart as text)=>
Let
Source = Json.Document(Web.Contents(“https://harvest.greenhouse.io/v1/jobs?”, [Headers=[AUTHORIZATION=”Basic xxxxxxxxxxxxx=”]])),
Query=[page=”&pagestart&”],