I have wasted a month already being chased from pillar to post by MPN Support.
We are busy developing a service to sync user profile images from Microsoft Graph to our system for clients using our SaaS platform.
The list of clients includes 200 clients and 4’000’000 Microsoft users about are affected by this.
Doing full sync daily from Microsoft Graph is suicide on our end and it will be murder on the Microsoft Graph infrastructure as we run these syncs globally across the globe for all our clients.
To get around us having to hammer the Graph service, we opted to run full sync once per client, store the delta token and then from that point on wards we only sync updates from the delta every 30 minutes.
During development, we tested this against one of our test tenants and it all went well. The full sync completes and from that point on wards we would see delta results if a user updated their profile image.
We then started testing against our primary tenant and some client tenants and very quickly found that the delta query was not returning results on updates on some tenants. This brought the entire project plan to a halt and as a result, we cannot sync profile images, forcing to stay on the current plan of over querying Graph with live queries.
I have tried this across 25 tenants and 5 are failing.
var usersDeltaLink = SQLService.GetDeltaToken(syncConfig);
List<string> memberIds = new List<string>();
var usersDeltaRequest = _graphClient
.Users
.Delta()
.Request(usersDeltaLink == null ? new Option[0] : new[]
{
new QueryOption("$deltatoken", usersDeltaLink.Token)
});
var deltaUsers = await usersDeltaRequest.GetAsync();
int totalCount = 0;
List<Task> itemTasks = new List<Task>();
do
{
totalCount += deltaUsers.Count;
Log.Information($"Fetched out {deltaUsers.Count} users");
itemTasks.AddRange(deltaUsers.CurrentPage.Select(x => x.Id).ToList().Select(async memAccId =>
{
await UpdateUserPhoto(memAccId);
}));
}
while (deltaUsers.NextPageRequest != null && (deltaUsers = await deltaUsers.NextPageRequest.GetAsync()).Count > 0);
await Task.WhenAll(itemTasks);
Uri deltaLink = new Uri(deltaUsers.AdditionalData["#odata.deltaLink"].ToString());
var deltaToken = HttpUtility.ParseQueryString(deltaLink.Query).Get("$deltatoken");
I expect Microsoft Graph Delta queries to work properly and not have 20% failure rate on tenants.
Turned out delta queries at the time is not supported on profile images.
https://microsoftgraph.uservoice.com/forums/920506-microsoft-graph-feature-requests/suggestions/38542960-delta-and-subscriptions-to-include-photo-and-photo
Related
I am attempting to programmatically retrieve a list of users (principalType = "User") and their associated appRoleId values for an enterprise app using itsresourceId value from Azure AD. There is a total of ten Users with a combined total of twenty appRoleId values associated with the app. However, when I run my query I receive data for just two users and a combined total of four appRoleId values.
Here's my C# code:
GraphServiceClient myGraphClient = GetGraphServiceClient([scopes]);
// Retrieve the [Id] value for the app. Note [Id] is a pseudonym for the [resourceId] required to retrieve users and app roles assigned.
var servPrinPage = await myGraphClient.ServicePrincipals.Request()
.Select("id,appRoles")
.Filter($"startswith(displayName, 'Display Name')")
.GetAsync()
.ConfigureAwait(false);
// Using the first [Id] value from the [ServicePrincipals] page, retrieve the list of users and their assigned roles for the app.
var appRoleAssignedTo = await myGraphClient.ServicePrincipals[servPrinPage[0].Id].AppRoleAssignedTo.Request().GetAsync().ConfigureAwait(false);
The query returns a ServicePrincipalAppRoleAssignedToCollectionPage (as expected) but the collection only contains four pages (one per User/appRoleId combination).
As an aside, the following query in Microsoft Graph Explorer produces an equivalent result:
https://graph.microsoft.com/v1.0/servicePrincipals/[resourceId]/appRoleAssignedTo
What am I missing here? I need to be able to retrieve the complete list of users and assigned app roles. Any assistance is greatly appreciated.
The issue I was confronting has to do with the pagination feature employed by Azure AD and MS Graph. In a nutshell, I was forced to submit two queries in order to retrieve all twenty records I was expecting.
If you have a larger set of records to be retrieved you may be faced with submitting a much larger number of successive queries. The successive queries are managed using a "skiptoken" passed as a request header each time your query is resubmitted.
Here is my revised code with notation....
// Step #1: Create a class in order to strongly type the <List> which will hold your results.
// Not absolutely necessary but always a good idea when working with <Lists> in C#.
private class AppRoleByUser
{
public string AzureDisplayName;
public string PrincipalDisplayName;
}
// Step #2: Submit a query to acquire the [id] for the Service Principal (i.e. your app).
// Note the [ServicePrincipals].[id] property is synonymous with the [resourceId] needed to
// retrieve [AppRoleAssignedTo] values from Microsoft Graph in the next step.
// Initialize the Microsoft Graph Client.
GraphServiceClient myGraphClient = GetGraphServiceClient("Directory.Read.All");
// Retrieve the Service Principals page containing the app [Id].
var servPrinPage = await myGraphClient.ServicePrincipals.Request().Select("id,appRoles").Filter($"startswith(displayName, 'Your App Name')").GetAsync().ConfigureAwait(false);
// Store the app [Id] in a local variable (for readability).
string resourceId = servPrinPage[0].Id;
// Step #3: Using the [Id]/[ResourceId] value from the previous step, retrieve a list of AppRoleId/DisplayName pairs for your app.
// Results of the successive queries are typed against the class created earlier and are appended to the <List>.
List<AppRoleByUser> appRoleByUser = new List<AppRoleByUser>();
// Note, unlike "Filter" or "Search" parameters, it is not possible to
// add a "Skiptoken" parameter directly to your query in C#.
// Instead, it is necessary to insert the "skiptoken" as request header using the Graph QueryOption class.
// Note the QueryOption List is passed as an empty object on the first pass of the while loop.
var queryOptions = new List<QueryOption>();
// Initialize the variable to hold the anticipated query result.
ServicePrincipalAppRoleAssignedToCollectionPage appRoleAssignedTo = new ServicePrincipalAppRoleAssignedToCollectionPage();
// Note the number of user/role combinations associated with an app is not always known.
// Consequently, you may be faced with the need to acquire multiple pages
// (and submit multiple consecutive queries) in order to obtain a complete
// listing of user/role combinations.
// The "while" loop construct will be utilized to manage query iteration.
// Execution of the "while" loop will be stopped when the "bRepeat" variable is set to false.
bool bRepeat = true;
while (bRepeat == true)
{
appRoleAssignedTo = (ServicePrincipalAppRoleAssignedToCollectionPage) await myGraphClient.ServicePrincipals[resourceId].AppRoleAssignedTo.Request(queryOptions).GetAsync().ConfigureAwait(false);
foreach (AppRoleAssignment myPage in appRoleAssignedTo)
{
// I was not able to find a definitive answer in any of the documents I
// found but it appears the final record in the recordset carries a
// [PrincipalType] = "Group" (all others carry a [PrincipalType] = "User").
if (myPage.PrincipalType != "Group")
{
// Insert "User" data into the List<AppRoleByUser> collection.
appRoleByUser.Add(new AppRoleByUser{ AzureDisplayName = myPage.PrincipalDisplayName, AzureUserRole = myPage.AppRoleId.ToString() });
}
else
{
// The "bRepeat" variable is initially set to true and is set to
// false when the "Group" record is detected thus signaling
// task completion and closing execution of the "while" loop.
bRepeat = false;
}
}
// Acquire the "nextLink" string from the response header.
// The "nextLink" string contains the "skiptoken" string required for the next
// iteration of the query.
string nextLinkValue = appRoleAssignedTo.AdditionalData["#odata.nextLink"].ToString();
// Parse the "skiptoken" value from the response header.
string skipToken = nextLinkValue.Substring(nextLinkValue.IndexOf("=") + 1);
// Include the "skiptoken" as a request header in the next iteration of the query.
queryOptions = new List<QueryOption>()
{
new QueryOption("$skiptoken", skipToken)
};
}
That's a long answer to what should have been a simple question. I am relatively new to Microsoft Graph but it appears to me Microsoft has a long way to go in making this API developer-friendly. All I needed was to know the combination of AppRoles and Users associated with a single, given Azure AD app. One query and one response should have been more than sufficient.
At any rate, I hope my toil might help save time for someone else going forward.
Could you please remove "Filter" from the code and retry the operation. Let us know if that worked.
In converting from an older bit of code that uses the EWS (ews-java-api v 2.0) SDK/API/Scope to Graph (microsoft-graph v5.4.0), I found that I could search (say by InternetMessageId) across multiple folder hierarchies at once in EWS with (simplifying how to get FolderId values a bit):
SearchFilter.SearchFilterCollection filter =
new SearchFilter.SearchFilterCollection(LogicalOperator.And);
filter.add(new SearchFilter.IsEqualTo(EmailMessageSchema.InternetMessageId, msgId));
List<FolderId> folders = Arrays.asList(new FolderId("AllItems"), new FolderId("Deletions"));
ItemView view = new ItemView(10);
ServiceResponseCollection<FindItemResponse<Item>> findResultsCollection =
service.findItems(searchFolders, filter, null, view, null, ReturnErrors);
With that EWS search whether my message of interest is in the Inbox, some user-created sub-folder, JunkEmail, DeletedItems, RecoverableItemsDeletions I find it by InternetMessageId in one go.
With Graph I issue two calls to be able to ensure the message does not exist
UserRequestBuilder u = GraphServiceClient
.builder()
.authenticationProvider(authenticationProvider)
.buildClient()
.users(user);
for (String folderTree : Arrays.asList("AllItems", "RecoverableItemsDeletions")) {
MessageCollectionPage mcp = u.mailFolders(folderTree)
.messages()
.buildRequest()
.filter("internetMessageId eq '" + msgId + "'")
.get();
Is there a way to search multiple trees in one go with Graph to be more like the EWS path that took a List?
Use List Messages endpoint to get the messages in the signed-in user's mailbox (including the Deleted Items and Clutter folders).
Depending on the page size and mailbox data, getting messages from a mailbox can incur multiple requests. The default page size is 10 messages. Use $top to customize the page size, within the range of 1 and 1000.
To improve the operation response time, use $select to specify the exact properties you need. Refer documentation here.
Java Code Snippet -
GraphServiceClient graphClient = GraphServiceClient.builder().authenticationProvider( authProvider ).buildClient();
MessageCollectionPage messages = graphClient.me().messages()
.buildRequest()
.select("sender,subject")
.get();
By following the guide Create Your Own Google Pagespeed & Mobile Usability Tracking Google Sheet in 5 Steps I managed to set up mobile pagespeed score for a list of (up to 50) URLs.
However since late 2017 or something there is real data available from the Chrome User Experience Report that displays an average load time in seconds for a page based on chrome user data.
(This data is being used for example when using Pagespeed Insights by google.)
Instead of pulling a page score I as described above I would like to pull the average load time into my google sheet.
Is it possible to adapt the script used from the article above to pull load time in seconds instead of pagescore? Or is there any other way to do this?
Thanks in advance your help is much appreciated.
This is the script I run in script editor to get pagescore into google sheet according to the linked article with function =checkAll(C3):
/**
* Returns Mobile Pagespeed, Mobile Usability, and Desktop Pagespeed values in three adjacent columns
* by Cagri Sarigoz
*/
function checkAll(Url) {
//CHANGE YOUR API KEY WITH YOUR_API_KEY BELOW
var key = "AIzaSyB2SeOumbCd6YNfFWRg5Jo_WpISZi4gCFs";
var serviceUrlMobile = "https://www.googleapis.com/pagespeedonline/v2/runPagespeed?url="+Url+"&strategy=mobile&key="+key;
var serviceUrlDesktop = "https://www.googleapis.com/pagespeedonline/v2/runPagespeed?url="+Url+"&strategy=desktop&key="+key;
var array = [];
if (key == "YOUR_API_KEY")
return "Please enter your API key to the script";
var responseMobile = UrlFetchApp.fetch(serviceUrlMobile);
if(responseMobile.getResponseCode() == 200) {
var contentMobile = JSON.parse(responseMobile.getContentText());
if ( (contentMobile != null) && (contentMobile["ruleGroups"] != null) )
{
if (contentMobile["responseCode"] == 200)
{
var speedScoreMobile = contentMobile["ruleGroups"]["SPEED"]["score"];
var usabilityScoreMobile = contentMobile["ruleGroups"]["USABILITY"]["score"];
}
else
{
array.push(["Not Found!", "Not Found!", "Not Found!"]);
return array;
}
}
}
var responseDesktop = UrlFetchApp.fetch(serviceUrlDesktop);
if(responseDesktop.getResponseCode() == 200) {
var contentDesktop = JSON.parse(responseDesktop.getContentText());
if ( (contentDesktop != null) && (contentDesktop["ruleGroups"] != null) )
var speedScoreDesktop = contentDesktop["ruleGroups"]["SPEED"]["score"];
}
array.push([speedScoreMobile, usabilityScoreMobile, speedScoreDesktop]);
return array;
}
I am the writer of the blog post that you shared. As you said, the Google Apps Script there was using Google Pagespeed API v2. The current API version is v4, and v2 will be depreciated on June 30th.
So I updated the code with v4 on my own copy of the spreadsheet. You can make your own copy from here.
I also wanted to add the mobile-friendly test results but it turned out that Google Search Console's API quota restrictions were too tight, returning error almost all the time. So I commented out that part of the code for the time being.
I didn't have the time to update my blog post yet. You can see the new version of the script here.
I'm currently trying to develop an application that creates Skype meetings.
I'm leveraging the C# UCWA SDK and developing against Skype for Business online.
Meeting creation works fine if I only include people from the tenant in attendees, as soon as I include people not from the tenant in the meeting I get this error message:
{"code":"BadRequest","subcode":"ParameterValidationFailure","message":"Please check what you entered and try again.","debugInfo":{"diagnosticsCode":"2"}}
Here is my code sample
var meeting = new MyOnlineMeeting()
{
AccessLevel = AccessLevel.Everyone,
Attendees = new string[] { $"sip:{Settings.SkypeUserEmail}" }, //Adding anybody else than the service account makes it fail for now
Subject = series.Subject,
ExpirationTime = DateTime.Now.AddDays(3),
AutomaticLeaderAssignment = AutomaticLeaderAssignment.SameEnterprise,
Leaders = series.Organizers.Select(x => $"sip:{x.EmailAddress}").ToArray(),
LobbyBypassForPhoneUsers = LobbyBypassForPhoneUsers.Enabled,
PhoneUserAdmission = PhoneUserAdmission.Disabled
};
var dialIn = await client.OnlineMeetings.GetPhoneDialInInformation();
var meetings = await client.OnlineMeetings.GetMyOnlineMeetings();
var result = await meetings.Create(meeting);
Adding external users to the organizers properties works fine though.
My question is: how can I add external attendees to the meeting I'm creating? Is there anything specific around attendees?
After a few exchanges on the Microsoft Skype for Business MVP's private distribution list, it appears that attendees have to be part of the organization or otherwise the call will fail.
Submitted a Pull Request to update the latest version of the documentation
I'm writing some code where, most commonly, no results will be returned from a query against Entity Framework. This request has been submitted by some jQuery code, and if I reply with "no results", it's just going to turn around and make the same request again - so I'd like to not respond until either some results are available, or a reasonable amount of time (e.g. 30 seconds) have passed (however, I don't want to cache results for 30 seconds - 30 seconds is a reasonable amount of time to not send a response to the query - if results become available, I want them available "immediately")
How do I best go about this. I tried sleeping between re-querying, but it a) doesn't seem to be working (every request that starts with no results waits the full 30 seconds), and b) will tie up an asp.net thread.
So how do I convert my code to not tie up asp.net threads, and to respond once results are available?
[HttpGet]
public ActionResult LoadEventsSince(Guid lastEvent, int maxEvents)
{
maxEvents = Math.Min(50, maxEvents); //No more than 50
using (var dbctxt = new DbContext())
{
var evt = dbctxt.Events.Find(lastEvent);
var afterEvents = (from et in evt.Session.Events
where et.OccurredAt > evt.OccurredAt
orderby et.OccurredAt
select new { EventId = et.EventId, EventType = et.EventType, Control = et.Control, Value = et.Value }).Take(maxEvents);
var cycles = 30;
while (afterEvents.Count() == 0 && cycles-- > 0)
{
System.Threading.Thread.Sleep(1000);
}
return Json(afterEvents.ToArray(), JsonRequestBehavior.AllowGet);
}
}
check out this mix 11 session: "Pragmatic JavaScript jQuery & AJAX with ASP.NET".
At the very end of it (about 40-45 minutes into the session) there is a demo right for you.
I'm prety sure you'll say wow..
Damian Edwards promissed to post more about the technique on his blog, but we are yet to see it..
See > Reverse ajax Comet/Polling implementation for ASP.NET MVC?.
You need to go with long polling. It basically sends a request to the server and the server just keeps it in the queue. It accumulates all the queries and as soon as it receives some data it sends the response to each of the queued requests.
EDIT: Also this is interesting > Comet implementation for ASP.NET?