Skip to content

Commit 0c7387c

Browse files
authored
feat: PPT-2000 add Application api (#42)
* feat: PPT-2000 add Application api * fix: spec
1 parent 717d1cf commit 0c7387c

File tree

8 files changed

+509
-5
lines changed

8 files changed

+509
-5
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ Implements the Microsoft Office365 Graph API for the follow
4141
- List channel messages
4242
- Get channel message
4343
- Send channel message
44+
* Application
45+
- List Applications
46+
- Get Application
47+
- Create Application
48+
- Update Application
49+
- Delete Application
50+
- Application Add Password
51+
- Application Add/Get Service Principal
52+
- Application API Permissions grant Oauth2AdminGrant
53+
- Application API Permissions grant admin consent
4454

4555

4656
## Installation

shard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: office365
2-
version: 1.25.6
2+
version: 1.25.7
33

44
crystal: ">= 0.36.1"
55

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require "../spec_helper"
2+
3+
describe Office365::Application do
4+
it "List applications" do
5+
SpecHelper.mock_client_auth
6+
SpecHelper.mock_list_applications
7+
8+
client = Office365::Client.new(**SpecHelper.mock_credentials)
9+
list = client.list_applications
10+
list.size.should eq(1)
11+
end
12+
13+
it "creates application" do
14+
SpecHelper.mock_client_auth
15+
SpecHelper.mock_create_applications
16+
17+
client = Office365::Client.new(**SpecHelper.mock_credentials)
18+
app = Office365::Application.new(display_name: "Display Name")
19+
created_app = client.create_application(app)
20+
created_app.should_not be_nil
21+
created_app.sign_in_audience.should eq(Office365::SignInAud::AzureADandPersonalMicrosoftAccount)
22+
end
23+
24+
it "add password to existing application" do
25+
SpecHelper.mock_client_auth
26+
SpecHelper.mock_applications_add_pwd
27+
28+
client = Office365::Client.new(**SpecHelper.mock_credentials)
29+
pwd = client.application_add_pwd("my-app", "Password friendly name")
30+
pwd.should_not be_nil
31+
pwd.display_name.should eq("Password friendly name")
32+
end
33+
34+
it "get partial application contents" do
35+
SpecHelper.mock_client_auth
36+
SpecHelper.mock_get_application_id_and_web
37+
client = Office365::Client.new(**SpecHelper.mock_credentials)
38+
app = client.get_application("my-app", "id,web")
39+
app.web.should_not be_nil,
40+
app.web.not_nil!.redirect_uris.try &.size.should eq(2)
41+
end
42+
end

spec/chat_messages/channel_messages_spec.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ describe Office365::Places do
3838
channel_id = "19:[email protected]"
3939
client = Office365::Client.new(**SpecHelper.mock_credentials)
4040

41-
resp = client.send_channel_message(team_id, channel_id, "Hello World")
42-
resp.status_code.should eq(201)
41+
resp = client.send_channel_message(team_id, channel_id, "test")
42+
resp.body.content.should eq("test")
4343
end
4444
end
4545
end

spec/spec_helper.cr

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -710,8 +710,151 @@ module SpecHelper
710710

711711
def mock_channel_send_msg
712712
WebMock.stub(:post, "https://graph.microsoft.com/v1.0/teams/fbe2bf47-16c8-47cf-b4a5-4b9b187c508b/channels/19%3A4a95f7d8db4c4e7fae857bcebe0623e6%40thread.tacv2/messages")
713-
.with(body: "{\"body\":{\"content\":\"Hello World\",\"contentType\":\"TEXT\"}}", headers: {"Authorization" => "Bearer access_token", "Content-Type" => "application/json", "Prefer" => "IdType=\"ImmutableId\""})
714-
.to_return(status: 201, body: "")
713+
.with(body: "{\"body\":{\"content\":\"test\",\"contentType\":\"TEXT\"}}", headers: {"Authorization" => "Bearer access_token", "Content-Type" => "application/json", "Prefer" => "IdType=\"ImmutableId\""})
714+
.to_return(status: 201, body: mock_get_channel_message)
715+
end
716+
717+
def mock_list_applications
718+
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/applications")
719+
.to_return(body: mock_list_applications_resp)
720+
end
721+
722+
def mock_create_applications
723+
WebMock.stub(:post, "https://graph.microsoft.com/v1.0/applications")
724+
.to_return(body: mock_create_application_resp)
725+
end
726+
727+
def mock_applications_add_pwd
728+
WebMock.stub(:post, "https://graph.microsoft.com/v1.0/applications%28appId%3D%27my-app%27%29/addPassword")
729+
.with(body: "{\"passwordCredential\":{\"displayName\":\"Password friendly name\"}}", headers: {"Authorization" => "Bearer access_token", "Content-Type" => "application/json", "Prefer" => "IdType=\"ImmutableId\""})
730+
.to_return(body: mock_application_add_pwd_resp)
731+
end
732+
733+
def mock_get_application_id_and_web
734+
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/applications%28appId%3D%27my-app%27%29?%24select=id%2Cweb")
735+
.to_return(body: mock_get_app_id_and_web_resp)
736+
end
737+
738+
def mock_list_applications_resp
739+
%(
740+
{
741+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications",
742+
"value": [
743+
{
744+
"appId": "00000000-0000-0000-0000-000000000000",
745+
"identifierUris": [ "http://contoso/" ],
746+
"displayName": "My app",
747+
"publisherDomain": "contoso.com",
748+
"signInAudience": "AzureADMyOrg"
749+
}
750+
]
751+
}
752+
)
753+
end
754+
755+
def mock_create_application_resp
756+
%(
757+
{
758+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications/$entity",
759+
"id": "03ef14b0-ca33-4840-8f4f-d6e91916010e",
760+
"deletedDateTime": null,
761+
"isFallbackPublicClient": null,
762+
"appId": "631a96bc-a705-4eda-9f99-fdaf9f54f6a2",
763+
"applicationTemplateId": null,
764+
"identifierUris": [],
765+
"createdDateTime": "2019-09-17T19:10:35.2742618Z",
766+
"displayName": "Display name",
767+
"isDeviceOnlyAuthSupported": null,
768+
"groupMembershipClaims": null,
769+
"optionalClaims": null,
770+
"addIns": [],
771+
"publisherDomain": "contoso.com",
772+
"samlMetadataUrl": "https://graph.microsoft.com/2h5hjaj542de/app",
773+
"signInAudience": "AzureADandPersonalMicrosoftAccount",
774+
"tags": [],
775+
"tokenEncryptionKeyId": null,
776+
"api": {
777+
"requestedAccessTokenVersion": 2,
778+
"acceptMappedClaims": null,
779+
"knownClientApplications": [],
780+
"oauth2PermissionScopes": [],
781+
"preAuthorizedApplications": []
782+
},
783+
"appRoles": [],
784+
"publicClient": {
785+
"redirectUris": []
786+
},
787+
"info": {
788+
"termsOfServiceUrl": null,
789+
"supportUrl": null,
790+
"privacyStatementUrl": null,
791+
"marketingUrl": null,
792+
"logoUrl": null
793+
},
794+
"keyCredentials": [],
795+
"parentalControlSettings": {
796+
"countriesBlockedForMinors": [],
797+
"legalAgeGroupRule": "Allow"
798+
},
799+
"passwordCredentials": [],
800+
"requiredResourceAccess": [],
801+
"web": {
802+
"redirectUris": [],
803+
"homePageUrl": null,
804+
"logoutUrl": null,
805+
"implicitGrantSettings": {
806+
"enableIdTokenIssuance": false,
807+
"enableAccessTokenIssuance": false
808+
}
809+
}
810+
}
811+
812+
)
813+
end
814+
815+
def mock_application_add_pwd_resp
816+
%(
817+
{
818+
"customKeyIdentifier": null,
819+
"endDateTime": "2021-09-09T19:50:29.3086381Z",
820+
"keyId": "f0b0b335-1d71-4883-8f98-567911bfdca6",
821+
"startDateTime": "2019-09-09T19:50:29.3086381Z",
822+
"secretText": "[6gyXA5S20@MN+WRXAJ]I-TO7g1:h2P8",
823+
"hint": "[6g",
824+
"displayName": "Password friendly name"
825+
}
826+
)
827+
end
828+
829+
def mock_get_app_id_and_web_resp
830+
%(
831+
{
832+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications(id,web)/$entity",
833+
"id": "870cf357-927e-4d71-9f81-6ca278227636",
834+
"web": {
835+
"homePageUrl": null,
836+
"logoutUrl": "https://localhost/auth/logout",
837+
"redirectUris": [
838+
"https://example.com",
839+
"https://mydomain.com/auth/login"
840+
],
841+
"implicitGrantSettings": {
842+
"enableAccessTokenIssuance": false,
843+
"enableIdTokenIssuance": false
844+
},
845+
"redirectUriSettings": [
846+
{
847+
"uri": "https:/localhost:8843",
848+
"index": null
849+
},
850+
{
851+
"uri": "https://localhost:8843/auth/login",
852+
"index": null
853+
}
854+
]
855+
}
856+
}
857+
)
715858
end
716859
end
717860

src/applications.cr

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
module Office365::Applications
2+
def list_applications(filter : String? = nil) : Array(Application)
3+
params = URI::Params.new
4+
params["$select"] = filter.to_s if filter
5+
path = "/v1.0/applications"
6+
response = graph_request(graph_http_request(request_method: "GET", path: path, query: params))
7+
if response.success?
8+
Array(Application).from_json(response.body, "value")
9+
else
10+
raise "error listing applications #{response.status} (#{response.status_code}\n#{response.body}"
11+
end
12+
end
13+
14+
def get_application(app_id : String, filter : String? = nil) : Application
15+
params = URI::Params.new
16+
params["$select"] = filter.to_s if filter
17+
path = "/v1.0/applications(appId='#{app_id}')"
18+
response = graph_request(graph_http_request(request_method: "GET", path: path, query: params))
19+
if response.success?
20+
Application.from_json(response.body)
21+
else
22+
raise "error getting application #{response.status} (#{response.status_code}\n#{response.body}"
23+
end
24+
end
25+
26+
def create_application(app : Application) : Application
27+
request = graph_http_request(request_method: "POST", path: "/v1.0/applications", data: app.to_json)
28+
response = graph_request(request)
29+
if response.success?
30+
Application.from_json(response.body)
31+
else
32+
raise "error creating application #{response.status} (#{response.status_code}\n#{response.body}"
33+
end
34+
end
35+
36+
def delete_application(app_id : String) : Nil
37+
path = "/v1.0/applications(appId='#{app_id}')"
38+
response = graph_request(graph_http_request(request_method: "DELETE", path: path))
39+
raise "error deleting application #{response.status} (#{response.status_code}\n#{response.body}" unless response.success?
40+
end
41+
42+
def update_application(app_id : String, body : String) : Nil
43+
path = "/v1.0/applications(appId='#{app_id}')"
44+
response = graph_request(graph_http_request(request_method: "PATCH", path: path, data: body))
45+
raise "error patching application #{response.status} (#{response.status_code}\n#{response.body}" unless response.status_code == 204
46+
end
47+
48+
def application_add_pwd(app_id : String, display_name : String, start_date_time : Time? = nil, end_date_time : Time? = nil)
49+
path = "/v1.0/applications(appId='#{app_id}')/addPassword"
50+
creds = AppPasswordCredential.new(display_name, start_date_time, end_date_time)
51+
body = {"passwordCredential": creds}
52+
request = graph_http_request(request_method: "POST", path: path, data: body.to_json)
53+
response = graph_request(request)
54+
if response.success?
55+
AppPasswordCredential.from_json(response.body)
56+
else
57+
raise "error adding application password #{response.status} (#{response.status_code}\n#{response.body}"
58+
end
59+
end
60+
61+
def application_create_sp(app_id : String) : String
62+
path = "/v1.0/servicePrincipals"
63+
body = {"appId": app_id}
64+
request = graph_http_request(request_method: "POST", path: path, data: body.to_json)
65+
response = graph_request(request)
66+
if response.success?
67+
JSON.parse(response.body).as_h["id"].as_s
68+
else
69+
raise "error creating application service principal #{response.status} (#{response.status_code}\n#{response.body}"
70+
end
71+
end
72+
73+
def application_get_sp(app_id : String) : String
74+
params = URI::Params.new
75+
params["$filter"] = "appId eq '#{app_id}'"
76+
path = "/v1.0/servicePrincipals"
77+
response = graph_request(graph_http_request(request_method: "GET", path: path, query: params))
78+
if response.success?
79+
JSON.parse(response.body).as_h["value"].as_a.first.as_h["id"].as_s
80+
else
81+
raise "error getting application service principal #{response.status} (#{response.status_code}\n#{response.body}"
82+
end
83+
end
84+
85+
def application_upsert_sp(app_id : String) : String
86+
application_get_sp(app_id) rescue application_create_sp(app_id)
87+
end
88+
89+
def application_add_app_role_assignment(app_id : String, role_id : String) : Hash(String, JSON::Any)
90+
app_sp_id = application_upsert_sp(app_id)
91+
graph_resource_id = application_get_sp("00000003-0000-0000-c000-000000000000")
92+
application_add_app_role_assignment(app_sp_id, graph_resource_id, role_id)
93+
end
94+
95+
def application_add_app_role_assignment(app_sp_id : String, graph_sp_id : String, role_id : String) : Hash(String, JSON::Any)
96+
body = {
97+
"principalId": app_sp_id,
98+
"resourceId": graph_sp_id,
99+
"appRoleId": role_id,
100+
}
101+
path = "/v1.0/servicePrincipals/#{app_sp_id}/appRoleAssignments"
102+
request = graph_http_request(request_method: "POST", path: path, data: body.to_json)
103+
response = graph_request(request)
104+
if response.success?
105+
JSON.parse(response.body).as_h
106+
else
107+
raise "error creating application service app role assignment #{response.status} (#{response.status_code}\n#{response.body}"
108+
end
109+
end
110+
111+
def application_add_oauth2_permission_grant(app_id : String, scope : String) : Hash(String, JSON::Any)
112+
app_sp_id = application_upsert_sp(app_id)
113+
graph_resource_id = application_get_sp("00000003-0000-0000-c000-000000000000")
114+
application_add_oauth2_permission_grant(app_sp_id, graph_resource_id, scope)
115+
end
116+
117+
def application_add_oauth2_permission_grant(app_sp_id : String, graph_sp_id : String, scope : String) : Hash(String, JSON::Any)
118+
body = {
119+
"clientId": app_sp_id,
120+
"consentType": "AllPrincipals",
121+
"resourceId": graph_sp_id,
122+
"scope": scope,
123+
}
124+
path = "/v1.0/oauth2PermissionGrants"
125+
request = graph_http_request(request_method: "POST", path: path, data: body.to_json)
126+
response = graph_request(request)
127+
if response.success?
128+
JSON.parse(response.body).as_h
129+
else
130+
raise "error granting admin consent to delegated permissions #{response.status} (#{response.status_code}\n#{response.body}"
131+
end
132+
end
133+
end

src/client.cr

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require "./odata"
1010
require "./password_credentials"
1111
require "./places"
1212
require "./channel_messages"
13+
require "./applications"
1314

1415
module Office365
1516
USERS_BASE = "/v1.0/users"
@@ -29,6 +30,7 @@ module Office365
2930
include Office365::PasswordCredentials
3031
include Office365::Places
3132
include Office365::ChannelMessages
33+
include Office365::Applications
3234

3335
LOGIN_URI = URI.parse("https://login.microsoftonline.com")
3436
GRAPH_URI = URI.parse("https://graph.microsoft.com/")
@@ -166,6 +168,7 @@ module Office365
166168
property http_body : String
167169

168170
def initialize(@http_status, @http_body, @message = nil)
171+
super("#{@http_status} #{@message} reason: #{@http_body}")
169172
end
170173
end
171174
end

0 commit comments

Comments
 (0)