@ -0,0 +1,601 @@ |
|||
# Integrating MAUI Client via using OpenID Connect |
|||
This is a demonstration for connecting ABP backend from MAUI app via using openid connect. |
|||
|
|||
In this flow, a web browser will be opened when user tries to log in and user will perform login operation in the browser. Then IdentityServer will redirect user to application with login credentials (state, token etc.) will be handled by application. |
|||
|
|||
> This is by intent. The code flow does not allow the user to log in using a native view in the app. The reason being that this flow ensures that the username and password are never seen by the client (except the browser, which is part of the OS system - aka we trust it). You could enable using a native login view with the Resource Owner Password Credentials (ROPC) flow. But this is also an attack vector. Suppose someone makes a fraud duplicate of your application and tricking users into entering their credentials. The fraudulent app could store those credentials in-between. You just got to enjoy those tin-foil-hat moments when doing security. In other words, using the code flow does not give an attacker that opportunity and therefore is the recommended option for mobile clients. |
|||
> |
|||
> - [@Mark Allibone](https://mallibone.com/post/xamarin-oidc) |
|||
|
|||
By the way, my motivation for building this sample is presenting just another way for authentication. **Resource Owner Password Credentials** authentication is already provided and it's more common way to do. This is yet another way to authenticate users. |
|||
|
|||
## Source Code |
|||
You can also find source code on GitHub in ABP-Samples. |
|||
- [abpframework/abp-samples/MAUI-OpenId](https://github.com/abpframework/abp-samples/tree/master/MAUI-OpenId) |
|||
|
|||
## Creating projects |
|||
- Create an ABP project without UI |
|||
|
|||
```bash |
|||
abp new Acme.BookStore -t app --no-ui -d mongodb --no-random-ports |
|||
``` |
|||
|
|||
- Create a maui application |
|||
|
|||
```bash |
|||
mkdir maui |
|||
cd maui |
|||
dotnet new maui -n Acme.BookStore.MauiClient |
|||
``` |
|||
|
|||
There is a long way for configuring scopes and callback urls for both server and clients. We'll use [WebAuthenticator](https://docs.microsoft.com/en-us/xamarin/essentials/web-authenticator?tabs=android) to perform this operation. |
|||
|
|||
## Configuring IdentityServer |
|||
|
|||
- Go to DbMigrator folder and MAUI client in **appsettings.json**. Add following client code in **IdentityServer:Clients** path: |
|||
|
|||
```json |
|||
"BookStore_Maui": { |
|||
"ClientId": "BookStore_Maui", |
|||
"ClientSecret": "1q2w3e*", |
|||
"RootUrl": "bookstore://" |
|||
} |
|||
``` |
|||
|
|||
- Go to **IdentityServerDataSeedContributor** in Domain project under IdentityServer folder. Append following code section into **CreateClientsAsync()** method. |
|||
|
|||
```csharp |
|||
// Maui Client |
|||
var mauiClientId = configurationSection["BookStore_Maui:ClientId"]; |
|||
if (!mauiClientId.IsNullOrWhiteSpace()) |
|||
{ |
|||
var mauiRootUrl = configurationSection["BookStore_Maui:RootUrl"]; |
|||
|
|||
await CreateClientAsync( |
|||
name: mauiClientId, |
|||
scopes: commonScopes, |
|||
grantTypes: new[] { "authorization_code" }, |
|||
secret: configurationSection["BookStore_Maui:ClientSecret"]?.Sha256(), |
|||
requireClientSecret: false, |
|||
redirectUri: $"{mauiRootUrl}" |
|||
); |
|||
} |
|||
``` |
|||
|
|||
- Run DbMigrator |
|||
|
|||
- Then run HttpApi.Host |
|||
|
|||
### Configuring NGROK |
|||
Client will check configuration from `/.well-known/openid-configuration` path and it must be a secured connection between client & server. I prefer to use ngrok to open my backend app to entire web. |
|||
|
|||
- Go to [getting started](https://dashboard.ngrok.com/get-started/setup) page of ngrok _(login or register first)_ and download the ngrok tool. |
|||
|
|||
- Don't forget to login from tool: |
|||
|
|||
```bash |
|||
ngrok authtoken XXX |
|||
``` |
|||
|
|||
_A sample command is being displayed at dashboard where you download ngrok from_ |
|||
|
|||
- Open your HttpApi.Host with ngrok |
|||
|
|||
```bash |
|||
.\ngrok.exe http https://localhost:44350 |
|||
``` |
|||
|
|||
- You'll see a generated xxx.ngrok.io url. Navigate to `/.well-known/openid-configuration` to check if it's working right. |
|||
|
|||
You should see something like that: |
|||
 |
|||
Issuer must be your URL, not localhost! If you see still localhost, try to disable host header rewrite. |
|||
|
|||
|
|||
- Also, ValidIssuers must be defined to validate tokens. |
|||
|
|||
- Add **ValidIssuers** section to your `appsettings.json` of HttpApi.Host |
|||
|
|||
```js |
|||
"AuthServer": { |
|||
"Authority": "https://localhost:44350", |
|||
"RequireHttpsMetadata": "false", |
|||
"SwaggerClientId": "BookStore_Swagger", |
|||
"SwaggerClientSecret": "1q2w3e*", |
|||
"ValidIssuers": [ |
|||
"https://46fd-45-156-29-175.ngrok.io" |
|||
] |
|||
}, |
|||
``` |
|||
|
|||
- Then define it in **ConfigureAuthentication** method in Module class |
|||
|
|||
```csharp |
|||
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration) |
|||
{ |
|||
context.Services.AddAuthentication() |
|||
.AddJwtBearer(options => |
|||
{ |
|||
// ... |
|||
options.TokenValidationParameters.ValidIssuers = configuration.GetSection("AuthServer:ValidIssuers").Get<string[]>(); |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
We're done with backend. Let's continue with MAUI app. |
|||
|
|||
|
|||
## Developing MAUI App |
|||
|
|||
Before we go, there is something to do like configuring dependency injection to get rid of unnecessary huge class coupling. |
|||
|
|||
### Configuring Dependency Injection |
|||
|
|||
- Go to **MauiApplication** class and add `MainPage` in services. |
|||
|
|||
```csharp |
|||
public static MauiApp CreateMauiApp() |
|||
{ |
|||
var builder = MauiApp.CreateBuilder(); |
|||
builder |
|||
.UseMauiApp<App>() |
|||
.ConfigureFonts(fonts => |
|||
{ |
|||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); |
|||
}); |
|||
|
|||
builder.Services.AddTransient<MainPage>(); |
|||
|
|||
return builder.Build(); |
|||
} |
|||
``` |
|||
|
|||
- And inject MainPage from constructor in **App.xaml.cs** |
|||
|
|||
```csharp |
|||
public App(MainPage mainPage) |
|||
{ |
|||
InitializeComponent(); |
|||
|
|||
MainPage = mainPage; |
|||
} |
|||
``` |
|||
|
|||
Now MainPage is ready for injecting dependencies to it. |
|||
|
|||
|
|||
### Configuring OIDC |
|||
|
|||
- Add `IdentityModel.OidcClient` package to project |
|||
|
|||
```xml |
|||
<ItemGroup> |
|||
<PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" /> |
|||
</ItemGroup> |
|||
``` |
|||
|
|||
- Create **WebAuthenticatorBrowser** |
|||
|
|||
```csharp |
|||
internal class WebAuthenticatorBrowser : IBrowser |
|||
{ |
|||
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) |
|||
{ |
|||
try |
|||
{ |
|||
WebAuthenticatorResult authResult = |
|||
await WebAuthenticator.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl)); |
|||
var authorizeResponse = ToRawIdentityUrl(options.EndUrl, authResult); |
|||
|
|||
return new BrowserResult |
|||
{ |
|||
Response = authorizeResponse |
|||
}; |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Debug.WriteLine(ex); |
|||
return new BrowserResult() |
|||
{ |
|||
ResultType = BrowserResultType.UnknownError, |
|||
Error = ex.ToString() |
|||
}; |
|||
} |
|||
} |
|||
|
|||
public string ToRawIdentityUrl(string redirectUrl, WebAuthenticatorResult result) |
|||
{ |
|||
IEnumerable<string> parameters = result.Properties.Select(pair => $"{pair.Key}={pair.Value}"); |
|||
var values = string.Join("&", parameters); |
|||
|
|||
return $"{redirectUrl}#{values}"; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Configure **OidcClient** in **MauiProgram** |
|||
|
|||
```csharp |
|||
builder.Services.AddTransient<WebAuthenticatorBrowser>(); |
|||
|
|||
builder.Services.AddTransient<OidcClient>(sp => |
|||
new OidcClient(new OidcClientOptions |
|||
{ |
|||
// Use your own ngrok url: |
|||
Authority = "https://46fd-45-156-29-175.ngrok.io", |
|||
ClientId = "BookStore_Maui", |
|||
RedirectUri = "bookstore://", |
|||
Scope = "openid email profile role BookStore", |
|||
ClientSecret = "1q2w3E*", |
|||
Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(), |
|||
}) |
|||
); |
|||
``` |
|||
|
|||
- Go to **MainPage.xaml**, remove everyting and add a button for login |
|||
|
|||
```xml |
|||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
x:Class="MauiApp1.MainPage"> |
|||
|
|||
<ScrollView> |
|||
<Grid RowSpacing="25" RowDefinitions="Auto,Auto,Auto,Auto,*" |
|||
Padding="{OnPlatform iOS='30,60,30,30', Default='30'}"> |
|||
|
|||
<Button Text="Click to Log In" Clicked="OnLoginClicked" VerticalOptions="CenterAndExpand" HorizontalOptions="Center"/> |
|||
|
|||
</Grid> |
|||
</ScrollView> |
|||
</ContentPage> |
|||
``` |
|||
|
|||
- Inject **OidcClient** in **MainPage.xaml.cs** and make login operation. |
|||
|
|||
```csharp |
|||
using IdentityModel.OidcClient; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public partial class MainPage : ContentPage |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public MainPage(OidcClient oidcClient) |
|||
{ |
|||
InitializeComponent(); |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
await DisplayAlert("Login Result", "Access Token is:\n\n" + loginResult.AccessToken, "Close"); |
|||
|
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
It still won't work because there is something more to do for each platform. Check out the next step and configure the platforms that you're using. |
|||
|
|||
|
|||
## Platform Specific Configurations |
|||
|
|||
Each platform (UWP, OSX, iOS and Android) requires some configuration to use authentication from browser. In that step, we'll open a browser and user will login on the browser. After that, as you see in IdentityServer configurations, IdentityServer will redirect 'bookstore://' url that only contains scheme and that scheme is not http. Our application will handle that scheme and will be launched with parameters. |
|||
|
|||
### Android |
|||
|
|||
- Start with creating a new Activity named **BookStoreWebAuthenticatorCallbackActivity** |
|||
|
|||
```csharp |
|||
using Android.App; |
|||
using Android.Content; |
|||
using Android.Content.PM; |
|||
|
|||
namespace Acme.BookStore.MauiClient.Platforms.Android; |
|||
|
|||
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop)] |
|||
[IntentFilter(new[] { Intent.ActionView }, |
|||
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, |
|||
DataScheme = CALLBACK_SCHEME)] |
|||
public class BookStoreWebAuthenticatorCallbackActivity : Microsoft.Maui.Essentials.WebAuthenticatorCallbackActivity |
|||
{ |
|||
const string CALLBACK_SCHEME = "bookstore"; |
|||
} |
|||
``` |
|||
|
|||
- Add `CustomTabsService` to **AndroidManifest.xml** as below. _(queries tags only.)_ |
|||
|
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" /> |
|||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application> |
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
|||
<queries> |
|||
<intent> |
|||
<action android:name="android.support.customtabs.action.CustomTabsService" /> |
|||
</intent> |
|||
</queries> |
|||
</manifest> |
|||
``` |
|||
|
|||
> For some reason, an error occurs with my emulator while targeting SDK 31, so I've changed the target SDK to 30. |
|||
|
|||
- Run the Application and perform a login operation. |
|||
AccessToken will be retrieved. |
|||
|
|||
 |
|||
|
|||
--- |
|||
|
|||
### iOS/MacCatalyst |
|||
|
|||
- Add following key to **Info.plist** |
|||
|
|||
```xml |
|||
<key>CFBundleURLTypes</key> |
|||
<array> |
|||
<dict> |
|||
<key>CFBundleURLName</key> |
|||
<string>mauiessentials</string> |
|||
<key>CFBundleURLSchemes</key> |
|||
<array> |
|||
<string>bookstore</string> |
|||
</array> |
|||
<key>CFBundleTypeRole</key> |
|||
<string>Editor</string> |
|||
</dict> |
|||
</array> |
|||
``` |
|||
|
|||
- Open **AppDelegate** class and override `OpenUrl` and `ContinueUserActivity` methods |
|||
|
|||
```csharp |
|||
public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) |
|||
{ |
|||
if (Microsoft.Maui.Essentials.Platform.OpenUrl(app, url, options)) |
|||
return true; |
|||
|
|||
return base.OpenUrl(app, url, options); |
|||
} |
|||
|
|||
public override bool ContinueUserActivity(UIApplication application, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler) |
|||
{ |
|||
if (Microsoft.Maui.Platform.ContinueUserActivity(application, userActivity, completionHandler)) |
|||
return true; |
|||
return base.ContinueUserActivity(application, userActivity, completionHandler); |
|||
} |
|||
``` |
|||
|
|||
- Make all steps for MacCatalyst, too. |
|||
|
|||
|
|||
> **Tip:** If your IDE struggles while displaying references and namespace suggestions, make sure you're displaying that file with iOS Target Framework. |
|||
> |
|||
> You'll find it at the top of the editor. |
|||
>  |
|||
|
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
|
|||
--- |
|||
|
|||
### UWP (Windows) |
|||
|
|||
- Add following protocol extension in `Package.appxmanifest` file. |
|||
|
|||
```xml |
|||
<Applications> |
|||
<Application Id="App" |
|||
Executable="$targetnametoken$.exe" |
|||
EntryPoint="$targetentrypoint$"> |
|||
<Extensions> |
|||
<uap:Extension Category="windows.protocol"> |
|||
<uap:Protocol Name="bookstore"> |
|||
<uap:DisplayName>BookStore</uap:DisplayName> |
|||
</uap:Protocol> |
|||
</uap:Extension> |
|||
</Extensions> |
|||
</Application> |
|||
</Applications> |
|||
``` |
|||
|
|||
> Currently UWP has a bug in MAUI Essentials, I believe the MAUI team will solve it as soon as possible. You can track the status of issue: |
|||
> https://github.com/dotnet/maui/issues/2702 |
|||
|
|||
- That's it on Windows side. Run the application. |
|||
|
|||
|
|||
## Refreshing the access token |
|||
|
|||
IdentityServer doesn't return a refresh token by default. So we have to add `offline_access` to our scope while sending login request. |
|||
|
|||
- Add `offline_access` to scope in **MauiApplication.cs** that we configured before. |
|||
|
|||
```csharp |
|||
builder.Services.AddTransient<OidcClient>(sp => |
|||
new OidcClient(new OidcClientOptions |
|||
{ |
|||
// Use your own ngrok url: |
|||
Authority = "https://46fd-45-156-29-175.ngrok.io", |
|||
ClientId = "BookStore_Maui", |
|||
RedirectUri = "bookstore://", |
|||
Scope = "openid email profile role BookStore offline_access", // <-- Final state must be like this. |
|||
ClientSecret = "1q2w3E*", |
|||
Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(), |
|||
}) |
|||
); |
|||
``` |
|||
|
|||
- Then check if it's working or not in **MainPage.xaml.cs**. Update OnLoginClicked method as below |
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
await DisplayAlert("Login Result", "Access Token is:\n\n" + loginResult.AccessToken, "Close"); |
|||
|
|||
var refreshResult = await OidcClient.RefreshTokenAsync(loginResult.RefreshToken); |
|||
await DisplayAlert("Refresh Result", "New Access Token is: \n\n" + refreshResult.AccessToken, "Close"); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## Storing the access token |
|||
|
|||
In this step we have to store access token & refresh token for future requests. |
|||
|
|||
> [Secure Storage](https://docs.microsoft.com/en-us/xamarin/essentials/secure-storage?tabs=android) is highly recommended to store this kind of sensitive data. But it's not topic of this article. You can configure and use SecureStorage on your own. I'll go with `App Properties`. |
|||
|
|||
- Add following class to store key names instead of using magic strings in code. |
|||
```csharp |
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public static class OidcConsts |
|||
{ |
|||
internal const string AccessTokenKeyName = "__access_token"; |
|||
internal const string RefreshTokenKeyName = "__refresh_token"; |
|||
} |
|||
``` |
|||
|
|||
- Then go back to **MainPage.xaml.cs** and save our tokens after a successfull login. |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = loginResult.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = loginResult.RefreshToken; |
|||
|
|||
await App.Current.SavePropertiesAsync(); |
|||
|
|||
// Navigate to an inner page here. |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Add following **AccessTokenHttpMessageHandler** to append AccessToken to our requests & refresh token when required. |
|||
|
|||
```csharp |
|||
public class AccessTokenHttpMessageHandler : DelegatingHandler |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public AccessTokenHttpMessageHandler(OidcClient oidcClient) : base(new HttpClientHandler()) |
|||
{ |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) |
|||
{ |
|||
if (App.Current.Properties.TryGetValue(OidcConsts.AccessTokenKeyName, out object currentTokenValue) && currentTokenValue != null) |
|||
{ |
|||
request.SetBearerToken(currentTokenValue?.ToString()); |
|||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); |
|||
} |
|||
|
|||
var response = await base.SendAsync(request, cancellationToken); |
|||
|
|||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) |
|||
{ |
|||
if (App.Current.Properties.TryGetValue(OidcConsts.RefreshTokenKeyName, out object refreshTokenValue) && refreshTokenValue != null) |
|||
{ |
|||
var refreshResult = await OidcClient.RefreshTokenAsync(refreshTokenValue?.ToString()); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = refreshResult.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = refreshResult.RefreshToken; |
|||
await App.Current.SavePropertiesAsync(); |
|||
|
|||
request.SetBearerToken(refreshResult.AccessToken); |
|||
|
|||
return await base.SendAsync(request, cancellationToken); |
|||
} |
|||
else |
|||
{ |
|||
var result = await OidcClient.LoginAsync(new LoginRequest()); |
|||
request.SetBearerToken(result.AccessToken); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = result.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = result.RefreshToken; |
|||
await App.Current.SavePropertiesAsync(); |
|||
request.SetBearerToken(result.AccessToken); |
|||
|
|||
return await base.SendAsync(request, cancellationToken); |
|||
} |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Then register to dependency injection. |
|||
|
|||
```csharp |
|||
builder.Services.AddSingleton<AccessTokenHttpMessageHandler>(); |
|||
builder.Services.AddTransient<HttpClient>(sp => |
|||
new HttpClient(sp.GetRequiredService<AccessTokenHttpMessageHandler>()) |
|||
{ |
|||
BaseAddress = new Uri("https://46fd-45-156-29-175.ngrok.io") |
|||
}); |
|||
``` |
|||
|
|||
- Now make we can send request to backend with authentication. Go to **MainPage.xaml.cs** and send a request right after authentication. |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = loginResult.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = loginResult.RefreshToken; |
|||
|
|||
await App.Current.SavePropertiesAsync(); |
|||
|
|||
var json = await httpClient.GetStringAsync("/api/identity/users"); |
|||
|
|||
await DisplayAlert("/api/identity/users", json, "close"); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Following result will be returned from API. |
|||
|
|||
<img src="art/identity-users-request-result.png" height="480"> |
|||
|
|||
--- |
|||
|
|||
## Recap |
|||
|
|||
The purpose of this arcitle is connecting to ABP backend with access token and it's working properly. |
|||
|
|||
I'm planning to integrate HttpApi.Client library of backend project instead of making requests manually as a second part of this article. I'll get inspired by [hikalkan/maui-abp-playing](https://github.com/hikalkan/maui-abp-playing) repo to achive that. |
|||
|
|||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 646 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 46 KiB |
@ -0,0 +1,529 @@ |
|||
# Using ABP Client Proxies in MAUI with OpenID Connect |
|||
The purpose of this article is to integrate ABP Core into the MAUI project and initialize it as an **AbpModule** then make able consuming API using ABP IAppServices. |
|||
|
|||
Before we start, I offer my special thanks to [@hikalkan](https://github.com/hikalkan/maui-abp-playing) because this repository ( [hikalkan/maui-abp-playing](https://github.com/hikalkan/maui-abp-playing) ) is a fantastic inspiration for the purpose of this article. |
|||
|
|||
## Getting Started |
|||
|
|||
In this article, we'll work on an application that was built on the previous article: [Integrating MAUI Client via Using OpenID Connect](https://community.abp.io/posts/integrating-maui-client-via-using-openid-connect-aqjjwsdf). |
|||
|
|||
|
|||
## Source Code |
|||
Source code is available on GitHub: |
|||
[abpframework/abp-samples/MAUI-OpenId](https://github.com/abpframework/abp-samples/tree/master/MAUI-OpenId) |
|||
|
|||
|
|||
## Configuring ABP Core |
|||
|
|||
As a first step, Dependency Injection will be changed with module initialization. We have to initialize our application as an ABP Module first. |
|||
|
|||
- Add the following dependencies to MAUI Client. |
|||
|
|||
```xml |
|||
<PackageReference Include="Volo.Abp.Http.Client.IdentityModel" Version="5.1.3" /> |
|||
<PackageReference Include="Volo.Abp.Autofac" Version="5.1.3" /> |
|||
``` |
|||
|
|||
- Add HttpApi.Client project reference |
|||
|
|||
```xml |
|||
<ProjectReference Include="..\..\aspnet-core\src\Acme.BookStore.HttpApi.Client\Acme.BookStore.HttpApi.Client.csproj" /> |
|||
``` |
|||
And run `abp build` command under MAUI application folder. |
|||
|
|||
> `abp build` command is equivalent of `dotnet build /graphBuild`, it's like a shortcut to graphBuild. The graphBuild finds all dependency tree and build them recursively. |
|||
|
|||
|
|||
- Create **BookStoreMauiClientModule**. |
|||
|
|||
```csharp |
|||
using IdentityModel.OidcClient; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Http.Client.IdentityModel; |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[DependsOn( |
|||
typeof(AbpAutofacModule), |
|||
typeof(AbpHttpClientIdentityModelModule), |
|||
typeof(BookStoreHttpApiClientModule) |
|||
)] |
|||
public class BookStoreMauiClientModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var configuration = context.Services.GetConfiguration(); |
|||
|
|||
Configure<OidcClientOptions>(configuration.GetSection("Oidc:Options")); |
|||
|
|||
context.Services.AddTransient<OidcClient>(sp => |
|||
{ |
|||
var options = sp.GetRequiredService<IOptions<OidcClientOptions>>().Value; |
|||
options.Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(); |
|||
return new OidcClient(options); |
|||
}); |
|||
|
|||
context.Services.AddTransient<HttpClient>(sp => |
|||
new HttpClient(sp.GetRequiredService<AccessTokenHttpMessageHandler>()) |
|||
{ |
|||
// Temporarily. We'll use ABP's Proxy for sendind requests. |
|||
BaseAddress = new Uri(configuration.GetValue<string>("RemoteServices:Default:BaseUrl")) |
|||
}); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Mark all dependencies with interfaces for registering as services. |
|||
|
|||
```csharp |
|||
internal class WebAuthenticatorBrowser : IBrowser, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public partial class MainPage : ContentPage, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public class AccessTokenHttpMessageHandler : DelegatingHandler, ISingletonDependency |
|||
``` |
|||
|
|||
- Add `appsettings.json` file to root path of your application and mark it as **Embedded resource**. |
|||
|
|||
```json |
|||
{ |
|||
"Oidc": { |
|||
"Options": { |
|||
"Authority": "https://46fd-45-156-29-175.ngrok.io", |
|||
"ClientId": "BookStore_Maui", |
|||
"RedirectUri": "bookstore://", |
|||
"Scope": "openid email profile role BookStore offline_access", |
|||
"ClientSecret": "1q2w3E*" |
|||
} |
|||
}, |
|||
"RemoteServices": { |
|||
"Default": { |
|||
"BaseUrl": "https://46fd-45-156-29-175.ngrok.io" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Finally, Go back `MauiApplication.cs` and clear old codes and initialize ABP. |
|||
|
|||
```csharp |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.FileProviders; |
|||
using System.Reflection; |
|||
using Volo.Abp; |
|||
using Volo.Abp.Autofac; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public static class MauiProgram |
|||
{ |
|||
public static MauiApp CreateMauiApp() |
|||
{ |
|||
var builder = MauiApp.CreateBuilder(); |
|||
builder.ConfigureContainer(new AbpAutofacServiceProviderFactory(new Autofac.ContainerBuilder()), containerBuilder => |
|||
{ |
|||
|
|||
}); |
|||
builder |
|||
.UseMauiApp<App>() |
|||
.ConfigureFonts(fonts => |
|||
{ |
|||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); |
|||
}); |
|||
|
|||
ConfigureConfiguration(builder); |
|||
|
|||
builder.Services.AddApplication<BookStoreMauiClientModule>(options => |
|||
{ |
|||
options.Services.ReplaceConfiguration(builder.Configuration); |
|||
}); |
|||
|
|||
var app = builder.Build(); |
|||
|
|||
app.Services.GetRequiredService<IAbpApplicationWithExternalServiceProvider>() |
|||
.Initialize(app.Services); |
|||
|
|||
return app; |
|||
} |
|||
|
|||
private static void ConfigureConfiguration(MauiAppBuilder builder) |
|||
{ |
|||
var assembly = typeof(App).GetTypeInfo().Assembly; |
|||
builder.Configuration.AddJsonFile(new EmbeddedFileProvider(assembly), "appsettings.json", optional: false, false); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Now application is runnable and all behaviors are the same with previos state. But it uses power of ABP right now. |
|||
|
|||
### Switching to SecureStorage |
|||
.Net MAUI supports a secure storage by default. Before we go further, we need to switch to secure storage instead of using app properties. Just update login method as below at **MainPage.xaml.cs** |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
if (loginResult.IsError) |
|||
{ |
|||
await DisplayAlert("Error", loginResult.Error, "Close"); |
|||
return; |
|||
} |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
} |
|||
``` |
|||
|
|||
> Additionally, please configure each platform according to [Secure Storage documentation](https://docs.microsoft.com/en-us/xamarin/essentials/secure-storage?tabs=android) |
|||
|
|||
|
|||
## Configuring Client Proxies |
|||
|
|||
ABP Client-Proxies don't use HttpClient directly. They use `IHttpClientFactory` to activate a new HttpClient instead of injecting it directly. So, we won't need **AccessTokenHttpMessageHandler** anymore. But still there is a way needed to set access token in requests. No worries, ABP has `IRemoteServiceHttpClientAuthenticator` to do that operation. Implementing it and registering to container will solve that issue and the client will be able to make authorized request to server. |
|||
|
|||
|
|||
- Remove **AccessTokenHttpMessageHandler.cs** from the project. |
|||
|
|||
- Add **AccessTokenRemoteServiceHttpClientAuthenticator.cs** instead. |
|||
|
|||
```csharp |
|||
using IdentityModel.Client; |
|||
using IdentityModel.OidcClient; |
|||
using System.IdentityModel.Tokens.Jwt; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Http.Client.Authentication; |
|||
using DependencyAttribute = Volo.Abp.DependencyInjection.DependencyAttribute; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[Dependency(ReplaceServices = true)] |
|||
[ExposeServices(typeof(IRemoteServiceHttpClientAuthenticator))] |
|||
public class AccessTokenRemoteServiceHttpClientAuthenticator : IRemoteServiceHttpClientAuthenticator, ITransientDependency |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public AccessTokenRemoteServiceHttpClientAuthenticator(OidcClient oidcClient) |
|||
{ |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
public async Task Authenticate(RemoteServiceHttpClientAuthenticateContext context) |
|||
{ |
|||
var currentAccessToken = await SecureStorage.GetAsync(OidcConsts.AccessTokenKeyName); |
|||
|
|||
if (!currentAccessToken.IsNullOrEmpty()) |
|||
{ |
|||
// TODO: Find better way to find if token is expired instead of parsing it. |
|||
var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(currentAccessToken) as JwtSecurityToken; |
|||
if (jwtToken.ValidTo <= DateTime.UtcNow) |
|||
{ |
|||
var refreshToken = await SecureStorage.GetAsync(OidcConsts.RefreshTokenKeyName); |
|||
if (!refreshToken.IsNullOrEmpty()) |
|||
{ |
|||
var refreshResult = await OidcClient.RefreshTokenAsync(refreshToken); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, refreshResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, refreshResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(refreshResult.AccessToken); |
|||
} |
|||
else |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(loginResult.AccessToken); |
|||
} |
|||
} |
|||
|
|||
context.Request.SetBearerToken(currentAccessToken); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Now we are ready to inject IAppServices to communicate with backend. |
|||
|
|||
## Displaying Data in UI |
|||
|
|||
- Go back to **Acme.BookStore.Domain** project and add a simple data seed contributor to generate some example data for users. |
|||
|
|||
```csharp |
|||
public class UsersDataSeederContributor : IDataSeedContributor, ITransientDependency |
|||
{ |
|||
protected IIdentityUserRepository repository; |
|||
|
|||
protected IGuidGenerator guidGenerator; |
|||
public UsersDataSeederContributor(IIdentityUserRepository repository, IGuidGenerator guidGenerator) |
|||
{ |
|||
this.repository = repository; |
|||
this.guidGenerator = guidGenerator; |
|||
} |
|||
|
|||
public async Task SeedAsync(DataSeedContext context) |
|||
{ |
|||
var count = await repository.GetCountAsync(); |
|||
if(count <= 1) // Not sure 'admin' user was seeded before or not. |
|||
{ |
|||
// All the names below were generated by https://www.name-generator.org.uk/quick/ |
|||
// The names does not represent real people. |
|||
await repository.InsertManyAsync(new []{ |
|||
new IdentityUser(guidGenerator.Create(), "john.doe", "john.doe@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Zane.Frost", "Zane.Frost@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Oscar.Landry", "Oscar.Landry@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasemin.Roberts", "Yasemin.Roberts@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasmine.Perez", "Yasmine.Perez@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Tobi.Becker", "Tobi.Becker@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Fox.Gilmore", "Fox.Gilmore@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Benny.Burris", "Benny.Burris@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Chad.Camacho", "Chad.Camacho@abp.io"), |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the **Acme.BookStore.DbMigrator** project. |
|||
|
|||
- Turn back to MAUI app, and create a folder named **ViewModels** and add a simple `UsersViewModel.cs` under it. |
|||
|
|||
```csharp |
|||
public class UsersViewModel : BindableObject, ITransientDependency |
|||
{ |
|||
protected IIdentityUserAppService IdentityUserAppService { get; } |
|||
|
|||
public GetIdentityUsersInput Input { get; } = new(); |
|||
|
|||
public ObservableCollection<IdentityUserDto> Items { get; } = new(); |
|||
|
|||
public Command RefreshCommand { get; } |
|||
|
|||
private bool isBusy; |
|||
public bool IsBusy { get => isBusy; set => SetProperty(ref isBusy, value); } |
|||
|
|||
public UsersViewModel(IIdentityUserAppService identityUserAppService) |
|||
{ |
|||
IdentityUserAppService = identityUserAppService; |
|||
GetUsersAsync(); |
|||
RefreshCommand = new Command(GetUsersAsync); |
|||
} |
|||
|
|||
protected async void GetUsersAsync() |
|||
{ |
|||
if (IsBusy) |
|||
{ |
|||
return; // For preventing parallel request while searching. |
|||
} |
|||
|
|||
IsBusy = true; |
|||
|
|||
Items.Clear(); |
|||
|
|||
var result = await IdentityUserAppService.GetListAsync(Input); |
|||
foreach (var user in result.Items) |
|||
{ |
|||
Items.Add(user); |
|||
} |
|||
|
|||
IsBusy = false; |
|||
} |
|||
|
|||
protected void SetProperty<T>(ref T backField, T value, [CallerMemberName] string propertyName = null) |
|||
{ |
|||
backField = value; |
|||
OnPropertyChanged(propertyName); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Create a folder named **Pages** and add a content page named `UsersPage`. |
|||
|
|||
_(Make sure you're adding MAUI Content Page)_ |
|||
|
|||
 |
|||
|
|||
- And inject `UsersViewModel` into it. |
|||
|
|||
```csharp |
|||
public partial class UsersPage : ContentPage, ITransientDependency |
|||
{ |
|||
public UsersViewModel ViewModel { get; } |
|||
|
|||
public UsersPage(UsersViewModel viewModel) |
|||
{ |
|||
ViewModel = viewModel; |
|||
InitializeComponent(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- And use that ViewModel in XAML design page. |
|||
|
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
x:Class="Acme.BookStore.MauiClient.UsersPage" |
|||
Title="UsersPage" |
|||
x:Name="page" |
|||
BindingContext="{Binding ViewModel, Source={x:Reference page}}"> |
|||
<StackLayout> |
|||
<ListView |
|||
IsPullToRefreshEnabled="True" |
|||
ItemsSource="{Binding Items}" |
|||
IsRefreshing="{Binding IsBusy}" |
|||
RefreshCommand="{Binding RefreshCommand}"> |
|||
<ListView.Header> |
|||
<SearchBar Text="{Binding Input.Filter}" SearchCommand="{Binding RefreshCommand}" /> |
|||
</ListView.Header> |
|||
<ListView.ItemTemplate> |
|||
<DataTemplate> |
|||
<TextCell |
|||
Text="{Binding UserName, StringFormat='@{0}'}" |
|||
Detail="{Binding Email}"/> |
|||
</DataTemplate> |
|||
</ListView.ItemTemplate> |
|||
</ListView> |
|||
</StackLayout> |
|||
</ContentPage> |
|||
``` |
|||
|
|||
> I've used binding while setting **BindingContext** as **ViewModel** because of IntelliSense support. With this method, you'll see intellisense will suggest properties from your ViewModel. |
|||
> |
|||
>  |
|||
|
|||
|
|||
After a couple of try, I realized, only AppShell supports dependency injection while navigating between pages. So, adding a new AppShell will help to build app menus and navigating with route. We can pass parameters with querystring with this way. |
|||
|
|||
- Add `Shell Pagae (MAUI)` to root of your application with name **AppShell.xaml**. |
|||
|
|||
_I've got some help for design of shell page from microsoft's articles._ |
|||
|
|||
_Additionally, you might want to put [abp_icon.svg](maui/Acme.BookStore.MauiClient/Resources/Images/abp_logo.svg) file under your **Resources/Images** folder._ |
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<Shell x:Class="Acme.BookStore.MauiClient.AppShell" |
|||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
xmlns:local="clr-namespace:Acme.BookStore.MauiClient"> |
|||
<Shell.Resources> |
|||
<ResourceDictionary> |
|||
<Color x:Key="Primary">#512BD4</Color> |
|||
<Style x:Key="BaseStyle" TargetType="Element"> |
|||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.ForegroundColor" Value="White" /> |
|||
<Setter Property="Shell.TitleColor" Value="White" /> |
|||
<Setter Property="Shell.DisabledColor" Value="#B4FFFFFF" /> |
|||
<Setter Property="Shell.UnselectedColor" Value="#95FFFFFF" /> |
|||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.TabBarForegroundColor" Value="White"/> |
|||
<Setter Property="Shell.TabBarUnselectedColor" Value="#95FFFFFF"/> |
|||
<Setter Property="Shell.TabBarTitleColor" Value="White"/> |
|||
</Style> |
|||
<Style TargetType="TabBar" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style TargetType="FlyoutItem" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style Class="FlyoutItemLabelStyle" TargetType="Label"> |
|||
<Setter Property="TextColor" Value="White"></Setter> |
|||
<Setter Property="Margin" Value="16"></Setter> |
|||
</Style> |
|||
<Style Class="FlyoutItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{x:OnPlatform UWP=Transparent, iOS=White, Android=White}" /> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
<VisualState x:Name="Selected"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
|
|||
<Style Class="MenuItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
</ResourceDictionary> |
|||
</Shell.Resources> |
|||
|
|||
<FlyoutItem Title="Home"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" Route="main" /> |
|||
</FlyoutItem> |
|||
|
|||
<FlyoutItem Title="Users"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:UsersPage}" Route="UsersPage" /> |
|||
</FlyoutItem> |
|||
|
|||
<Shell.FlyoutHeader> |
|||
<StackLayout> |
|||
<Image |
|||
Source="abp_logo.svg" |
|||
HorizontalOptions="Center" |
|||
Margin="25"/> |
|||
</StackLayout> |
|||
</Shell.FlyoutHeader> |
|||
|
|||
</Shell> |
|||
``` |
|||
|
|||
- One more step is required. Go to **App.xaml.cs** and replace MainPage with AppShell. |
|||
|
|||
```csharp |
|||
public partial class App : Application |
|||
{ |
|||
public App() |
|||
{ |
|||
InitializeComponent(); |
|||
|
|||
MainPage = new AppShell(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the application. |
|||
|
|||
- Login once if you haven't done before. |
|||
|
|||
- Navigate to Users page with hamburger menu at the right top. |
|||
|
|||
| Android| iOS| |
|||
| --- | --- | |
|||
|  |  | |
|||
|
|||
| UWP | |
|||
| --- | |
|||
|  | |
|||
|
|||
| MacCatalyst | |
|||
| --- | |
|||
|  | |
|||
|
|||
|
|||
## Conclusion |
|||
ABP Framework can be implemented any platform that runs on dotnet without suffer. ABP provides reusable abstractions layers and HttpApi Clients. In this article we've used powerful ABP core features such as Dependency Injection, Client Proxies, Validation and more. |
|||
|
|||
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 53 KiB |
@ -0,0 +1,529 @@ |
|||
# Using ABP Client Proxies in MAUI with OpenID Connect |
|||
The purpose of this article is to integrate ABP Core into the MAUI project and initialize it as an **AbpModule** then make able consuming API using ABP IAppServices. |
|||
|
|||
Before we start, I offer my special thanks to [@hikalkan](https://github.com/hikalkan/maui-abp-playing) because this repository ( [hikalkan/maui-abp-playing](https://github.com/hikalkan/maui-abp-playing) ) is a fantastic inspiration for the purpose of this article. |
|||
|
|||
## Getting Started |
|||
|
|||
In this article, we'll work on an application that was built on the previous article: [Integrating MAUI Client via Using OpenID Connect](https://community.abp.io/posts/integrating-maui-client-via-using-openid-connect-aqjjwsdf). |
|||
|
|||
|
|||
## Source Code |
|||
Source code is available on GitHub: |
|||
[abpframework/abp-samples/MAUI-OpenId](https://github.com/abpframework/abp-samples/tree/master/MAUI-OpenId) |
|||
|
|||
|
|||
## Configuring ABP Core |
|||
|
|||
As a first step, Dependency Injection will be changed with module initialization. We have to initialize our application as an ABP Module first. |
|||
|
|||
- Add the following dependencies to MAUI Client. |
|||
|
|||
```xml |
|||
<PackageReference Include="Volo.Abp.Http.Client.IdentityModel" Version="5.1.3" /> |
|||
<PackageReference Include="Volo.Abp.Autofac" Version="5.1.3" /> |
|||
``` |
|||
|
|||
- Add HttpApi.Client project reference |
|||
|
|||
```xml |
|||
<ProjectReference Include="..\..\aspnet-core\src\Acme.BookStore.HttpApi.Client\Acme.BookStore.HttpApi.Client.csproj" /> |
|||
``` |
|||
And run `abp build` command under MAUI application folder. |
|||
|
|||
> `abp build` command is equivalent of `dotnet build /graphBuild`, it's like a shortcut to graphBuild. The graphBuild finds all dependency tree and build them recursively. |
|||
|
|||
|
|||
- Create **BookStoreMauiClientModule**. |
|||
|
|||
```csharp |
|||
using IdentityModel.OidcClient; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Http.Client.IdentityModel; |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[DependsOn( |
|||
typeof(AbpAutofacModule), |
|||
typeof(AbpHttpClientIdentityModelModule), |
|||
typeof(BookStoreHttpApiClientModule) |
|||
)] |
|||
public class BookStoreMauiClientModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var configuration = context.Services.GetConfiguration(); |
|||
|
|||
Configure<OidcClientOptions>(configuration.GetSection("Oidc:Options")); |
|||
|
|||
context.Services.AddTransient<OidcClient>(sp => |
|||
{ |
|||
var options = sp.GetRequiredService<IOptions<OidcClientOptions>>().Value; |
|||
options.Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(); |
|||
return new OidcClient(options); |
|||
}); |
|||
|
|||
context.Services.AddTransient<HttpClient>(sp => |
|||
new HttpClient(sp.GetRequiredService<AccessTokenHttpMessageHandler>()) |
|||
{ |
|||
// Temporarily. We'll use ABP's Proxy for sendind requests. |
|||
BaseAddress = new Uri(configuration.GetValue<string>("RemoteServices:Default:BaseUrl")) |
|||
}); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Mark all dependencies with interfaces for registering as services. |
|||
|
|||
```csharp |
|||
internal class WebAuthenticatorBrowser : IBrowser, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public partial class MainPage : ContentPage, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public class AccessTokenHttpMessageHandler : DelegatingHandler, ISingletonDependency |
|||
``` |
|||
|
|||
- Add `appsettings.json` file to root path of your application and mark it as **Embedded resource**. |
|||
|
|||
```json |
|||
{ |
|||
"Oidc": { |
|||
"Options": { |
|||
"Authority": "https://46fd-45-156-29-175.ngrok.io", |
|||
"ClientId": "BookStore_Maui", |
|||
"RedirectUri": "bookstore://", |
|||
"Scope": "openid email profile role BookStore offline_access", |
|||
"ClientSecret": "1q2w3E*" |
|||
} |
|||
}, |
|||
"RemoteServices": { |
|||
"Default": { |
|||
"BaseUrl": "https://46fd-45-156-29-175.ngrok.io" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Finally, Go back `MauiApplication.cs` and clear old codes and initialize ABP. |
|||
|
|||
```csharp |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.FileProviders; |
|||
using System.Reflection; |
|||
using Volo.Abp; |
|||
using Volo.Abp.Autofac; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public static class MauiProgram |
|||
{ |
|||
public static MauiApp CreateMauiApp() |
|||
{ |
|||
var builder = MauiApp.CreateBuilder(); |
|||
builder.ConfigureContainer(new AbpAutofacServiceProviderFactory(new Autofac.ContainerBuilder()), containerBuilder => |
|||
{ |
|||
|
|||
}); |
|||
builder |
|||
.UseMauiApp<App>() |
|||
.ConfigureFonts(fonts => |
|||
{ |
|||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); |
|||
}); |
|||
|
|||
ConfigureConfiguration(builder); |
|||
|
|||
builder.Services.AddApplication<BookStoreMauiClientModule>(options => |
|||
{ |
|||
options.Services.ReplaceConfiguration(builder.Configuration); |
|||
}); |
|||
|
|||
var app = builder.Build(); |
|||
|
|||
app.Services.GetRequiredService<IAbpApplicationWithExternalServiceProvider>() |
|||
.Initialize(app.Services); |
|||
|
|||
return app; |
|||
} |
|||
|
|||
private static void ConfigureConfiguration(MauiAppBuilder builder) |
|||
{ |
|||
var assembly = typeof(App).GetTypeInfo().Assembly; |
|||
builder.Configuration.AddJsonFile(new EmbeddedFileProvider(assembly), "appsettings.json", optional: false, false); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Now application is runnable and all behaviors are the same with previos state. But it uses power of ABP right now. |
|||
|
|||
### Switching to SecureStorage |
|||
.Net MAUI supports a secure storage by default. Before we go further, we need to switch to secure storage instead of using app properties. Just update login method as below at **MainPage.xaml.cs** |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
if (loginResult.IsError) |
|||
{ |
|||
await DisplayAlert("Error", loginResult.Error, "Close"); |
|||
return; |
|||
} |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
} |
|||
``` |
|||
|
|||
> Additionally, please configure each platform according to [Secure Storage documentation](https://docs.microsoft.com/en-us/xamarin/essentials/secure-storage?tabs=android) |
|||
|
|||
|
|||
## Configuring Client Proxies |
|||
|
|||
ABP Client-Proxies don't use HttpClient directly. They use `IHttpClientFactory` to activate a new HttpClient instead of injecting it directly. So, we won't need **AccessTokenHttpMessageHandler** anymore. But still there is a way needed to set access token in requests. No worries, ABP has `IRemoteServiceHttpClientAuthenticator` to do that operation. Implementing it and registering to container will solve that issue and the client will be able to make authorized request to server. |
|||
|
|||
|
|||
- Remove **AccessTokenHttpMessageHandler.cs** from the project. |
|||
|
|||
- Add **AccessTokenRemoteServiceHttpClientAuthenticator.cs** instead. |
|||
|
|||
```csharp |
|||
using IdentityModel.Client; |
|||
using IdentityModel.OidcClient; |
|||
using System.IdentityModel.Tokens.Jwt; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Http.Client.Authentication; |
|||
using DependencyAttribute = Volo.Abp.DependencyInjection.DependencyAttribute; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[Dependency(ReplaceServices = true)] |
|||
[ExposeServices(typeof(IRemoteServiceHttpClientAuthenticator))] |
|||
public class AccessTokenRemoteServiceHttpClientAuthenticator : IRemoteServiceHttpClientAuthenticator, ITransientDependency |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public AccessTokenRemoteServiceHttpClientAuthenticator(OidcClient oidcClient) |
|||
{ |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
public async Task Authenticate(RemoteServiceHttpClientAuthenticateContext context) |
|||
{ |
|||
var currentAccessToken = await SecureStorage.GetAsync(OidcConsts.AccessTokenKeyName); |
|||
|
|||
if (!currentAccessToken.IsNullOrEmpty()) |
|||
{ |
|||
// TODO: Find better way to find if token is expired instead of parsing it. |
|||
var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(currentAccessToken) as JwtSecurityToken; |
|||
if (jwtToken.ValidTo <= DateTime.UtcNow) |
|||
{ |
|||
var refreshToken = await SecureStorage.GetAsync(OidcConsts.RefreshTokenKeyName); |
|||
if (!refreshToken.IsNullOrEmpty()) |
|||
{ |
|||
var refreshResult = await OidcClient.RefreshTokenAsync(refreshToken); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, refreshResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, refreshResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(refreshResult.AccessToken); |
|||
} |
|||
else |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(loginResult.AccessToken); |
|||
} |
|||
} |
|||
|
|||
context.Request.SetBearerToken(currentAccessToken); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Now we are ready to inject IAppServices to communicate with backend. |
|||
|
|||
## Displaying Data in UI |
|||
|
|||
- Go back to **Acme.BookStore.Domain** project and add a simple data seed contributor to generate some example data for users. |
|||
|
|||
```csharp |
|||
public class UsersDataSeederContributor : IDataSeedContributor, ITransientDependency |
|||
{ |
|||
protected IIdentityUserRepository repository; |
|||
|
|||
protected IGuidGenerator guidGenerator; |
|||
public UsersDataSeederContributor(IIdentityUserRepository repository, IGuidGenerator guidGenerator) |
|||
{ |
|||
this.repository = repository; |
|||
this.guidGenerator = guidGenerator; |
|||
} |
|||
|
|||
public async Task SeedAsync(DataSeedContext context) |
|||
{ |
|||
var count = await repository.GetCountAsync(); |
|||
if(count <= 1) // Not sure 'admin' user was seeded before or not. |
|||
{ |
|||
// All the names below were generated by https://www.name-generator.org.uk/quick/ |
|||
// The names does not represent real people. |
|||
await repository.InsertManyAsync(new []{ |
|||
new IdentityUser(guidGenerator.Create(), "john.doe", "john.doe@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Zane.Frost", "Zane.Frost@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Oscar.Landry", "Oscar.Landry@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasemin.Roberts", "Yasemin.Roberts@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasmine.Perez", "Yasmine.Perez@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Tobi.Becker", "Tobi.Becker@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Fox.Gilmore", "Fox.Gilmore@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Benny.Burris", "Benny.Burris@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Chad.Camacho", "Chad.Camacho@abp.io"), |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the **Acme.BookStore.DbMigrator** project. |
|||
|
|||
- Turn back to MAUI app, and create a folder named **ViewModels** and add a simple `UsersViewModel.cs` under it. |
|||
|
|||
```csharp |
|||
public class UsersViewModel : BindableObject, ITransientDependency |
|||
{ |
|||
protected IIdentityUserAppService IdentityUserAppService { get; } |
|||
|
|||
public GetIdentityUsersInput Input { get; } = new(); |
|||
|
|||
public ObservableCollection<IdentityUserDto> Items { get; } = new(); |
|||
|
|||
public Command RefreshCommand { get; } |
|||
|
|||
private bool isBusy; |
|||
public bool IsBusy { get => isBusy; set => SetProperty(ref isBusy, value); } |
|||
|
|||
public UsersViewModel(IIdentityUserAppService identityUserAppService) |
|||
{ |
|||
IdentityUserAppService = identityUserAppService; |
|||
GetUsersAsync(); |
|||
RefreshCommand = new Command(GetUsersAsync); |
|||
} |
|||
|
|||
protected async void GetUsersAsync() |
|||
{ |
|||
if (IsBusy) |
|||
{ |
|||
return; // For preventing parallel request while searching. |
|||
} |
|||
|
|||
IsBusy = true; |
|||
|
|||
Items.Clear(); |
|||
|
|||
var result = await IdentityUserAppService.GetListAsync(Input); |
|||
foreach (var user in result.Items) |
|||
{ |
|||
Items.Add(user); |
|||
} |
|||
|
|||
IsBusy = false; |
|||
} |
|||
|
|||
protected void SetProperty<T>(ref T backField, T value, [CallerMemberName] string propertyName = null) |
|||
{ |
|||
backField = value; |
|||
OnPropertyChanged(propertyName); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Create a folder named **Pages** and add a content page named `UsersPage`. |
|||
|
|||
_(Make sure you're adding MAUI Content Page)_ |
|||
|
|||
 |
|||
|
|||
- And inject `UsersViewModel` into it. |
|||
|
|||
```csharp |
|||
public partial class UsersPage : ContentPage, ITransientDependency |
|||
{ |
|||
public UsersViewModel ViewModel { get; } |
|||
|
|||
public UsersPage(UsersViewModel viewModel) |
|||
{ |
|||
ViewModel = viewModel; |
|||
InitializeComponent(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- And use that ViewModel in XAML design page. |
|||
|
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
x:Class="Acme.BookStore.MauiClient.UsersPage" |
|||
Title="UsersPage" |
|||
x:Name="page" |
|||
BindingContext="{Binding ViewModel, Source={x:Reference page}}"> |
|||
<StackLayout> |
|||
<ListView |
|||
IsPullToRefreshEnabled="True" |
|||
ItemsSource="{Binding Items}" |
|||
IsRefreshing="{Binding IsBusy}" |
|||
RefreshCommand="{Binding RefreshCommand}"> |
|||
<ListView.Header> |
|||
<SearchBar Text="{Binding Input.Filter}" SearchCommand="{Binding RefreshCommand}" /> |
|||
</ListView.Header> |
|||
<ListView.ItemTemplate> |
|||
<DataTemplate> |
|||
<TextCell |
|||
Text="{Binding UserName, StringFormat='@{0}'}" |
|||
Detail="{Binding Email}"/> |
|||
</DataTemplate> |
|||
</ListView.ItemTemplate> |
|||
</ListView> |
|||
</StackLayout> |
|||
</ContentPage> |
|||
``` |
|||
|
|||
> I've used binding while setting **BindingContext** as **ViewModel** because of IntelliSense support. With this method, you'll see intellisense will suggest properties from your ViewModel. |
|||
> |
|||
>  |
|||
|
|||
|
|||
After a couple of try, I realized, only AppShell supports dependency injection while navigating between pages. So, adding a new AppShell will help to build app menus and navigating with route. We can pass parameters with querystring with this way. |
|||
|
|||
- Add `Shell Pagae (MAUI)` to root of your application with name **AppShell.xaml**. |
|||
|
|||
_I've got some help for design of shell page from microsoft's articles._ |
|||
|
|||
_Additionally, you might want to put [abp_icon.svg](maui/Acme.BookStore.MauiClient/Resources/Images/abp_logo.svg) file under your **Resources/Images** folder._ |
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<Shell x:Class="Acme.BookStore.MauiClient.AppShell" |
|||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
xmlns:local="clr-namespace:Acme.BookStore.MauiClient"> |
|||
<Shell.Resources> |
|||
<ResourceDictionary> |
|||
<Color x:Key="Primary">#512BD4</Color> |
|||
<Style x:Key="BaseStyle" TargetType="Element"> |
|||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.ForegroundColor" Value="White" /> |
|||
<Setter Property="Shell.TitleColor" Value="White" /> |
|||
<Setter Property="Shell.DisabledColor" Value="#B4FFFFFF" /> |
|||
<Setter Property="Shell.UnselectedColor" Value="#95FFFFFF" /> |
|||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.TabBarForegroundColor" Value="White"/> |
|||
<Setter Property="Shell.TabBarUnselectedColor" Value="#95FFFFFF"/> |
|||
<Setter Property="Shell.TabBarTitleColor" Value="White"/> |
|||
</Style> |
|||
<Style TargetType="TabBar" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style TargetType="FlyoutItem" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style Class="FlyoutItemLabelStyle" TargetType="Label"> |
|||
<Setter Property="TextColor" Value="White"></Setter> |
|||
<Setter Property="Margin" Value="16"></Setter> |
|||
</Style> |
|||
<Style Class="FlyoutItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{x:OnPlatform UWP=Transparent, iOS=White, Android=White}" /> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
<VisualState x:Name="Selected"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
|
|||
<Style Class="MenuItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
</ResourceDictionary> |
|||
</Shell.Resources> |
|||
|
|||
<FlyoutItem Title="Home"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" Route="main" /> |
|||
</FlyoutItem> |
|||
|
|||
<FlyoutItem Title="Users"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:UsersPage}" Route="UsersPage" /> |
|||
</FlyoutItem> |
|||
|
|||
<Shell.FlyoutHeader> |
|||
<StackLayout> |
|||
<Image |
|||
Source="abp_logo.svg" |
|||
HorizontalOptions="Center" |
|||
Margin="25"/> |
|||
</StackLayout> |
|||
</Shell.FlyoutHeader> |
|||
|
|||
</Shell> |
|||
``` |
|||
|
|||
- One more step is required. Go to **App.xaml.cs** and replace MainPage with AppShell. |
|||
|
|||
```csharp |
|||
public partial class App : Application |
|||
{ |
|||
public App() |
|||
{ |
|||
InitializeComponent(); |
|||
|
|||
MainPage = new AppShell(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the application. |
|||
|
|||
- Login once if you haven't done before. |
|||
|
|||
- Navigate to Users page with hamburger menu at the right top. |
|||
|
|||
| Android| iOS| |
|||
| --- | --- | |
|||
|  |  | |
|||
|
|||
| UWP | |
|||
| --- | |
|||
|  | |
|||
|
|||
| MacCatalyst | |
|||
| --- | |
|||
|  | |
|||
|
|||
|
|||
## Conclusion |
|||
ABP Framework can be implemented any platform that runs on dotnet without suffer. ABP provides reusable abstractions layers and HttpApi Clients. In this article we've used powerful ABP core features such as Dependency Injection, Client Proxies, Validation and more. |
|||
|
|||
@ -1,100 +1,6 @@ |
|||
# Multi Lingual Entities |
|||
|
|||
ABP Framework defines two basic interfaces for Multi-Lingual entity definitions to provide a standard model for translating entities. |
|||
This feature is still under development. |
|||
Follow the below link to get information about the development status |
|||
|
|||
## IHasMultiLingual |
|||
|
|||
`IHasMultiLingual<TTranslation>` interface is used to mark multi lingual entities. The entities marked with `IHasMultiLingual<TTranslation>` interface must define language-neutral information. The entities marked with `IHasMultiLingual<TTranslation>` contains a collection of Translations which contains language-dependent information. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
public class Product : Entity, IMultiLingualEntity<ProductTranslation> |
|||
{ |
|||
public decimal Price { get; set; } |
|||
|
|||
public ICollection<ProductTranslation> Translations { get; set; } |
|||
} |
|||
``` |
|||
|
|||
## IMultiLingualTranslation |
|||
|
|||
`IMultiLingualTranslation` interface is used to mark translation of a Multi-Lingual entity. The entities marked with `IMultiLingualTranslation` interface must define language dependent information. The entities marked with `IMultiLingualTranslation` contains Language field which contains a language code for the translation. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
public class ProductTranslation : Entity, IMultiLingualTranslation |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
public string Language { get; set; } |
|||
} |
|||
``` |
|||
|
|||
## Map to DTO object |
|||
|
|||
ABP provdies the [Object To Object Mapping](Object-To-Object-Mapping.md) system, you can implement the `IObjectMapper<TSource, TDestination>` interface to map multi lingual entities to DTOs. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
public class MultiLingualProductObjectMapper : IObjectMapper<Product, ProductDto>, ITransientDependency |
|||
{ |
|||
private readonly IMultiLingualObjectManager _multiLingualObjectManager; |
|||
|
|||
public MultiLingualProductObjectMapper(IMultiLingualObjectManager multiLingualObjectManager) |
|||
{ |
|||
_multiLingualObjectManager = multiLingualObjectManager; |
|||
} |
|||
|
|||
public ProductDto Map(Product source) |
|||
{ |
|||
var translation = _multiLingualObjectManager.GetTranslation<Product, ProductDto>(source); |
|||
|
|||
return new ProductDto |
|||
{ |
|||
Price = source.Price, |
|||
Id = source.Id, |
|||
Name = translation?.Name |
|||
}; |
|||
} |
|||
|
|||
public ProductDto Map(Product source, ProductDto destination) |
|||
{ |
|||
return default; |
|||
} |
|||
} |
|||
|
|||
``` |
|||
|
|||
### AutoMapper integration |
|||
|
|||
ABP provides the `CreateMultiLingualMap` extension method for mapping multilingual entities to DTOs. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
public class ProductProfile : Profile |
|||
{ |
|||
public ProductProfile() |
|||
{ |
|||
var mapResult = this.CreateMultiLingualMap<Product, ProductTranslation, ProductDto>(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`CreateMultiLingualMap` extension method returns an object of type `CreateMultiLingualMapResult` which contains `EntityMap` and `TranslationMap` fields. These fields can be used to customize multi lingual mapping. |
|||
|
|||
Example: |
|||
|
|||
```csharp |
|||
this.CreateMultiLingualMap<Order, OrderTranslation, OrderListDto>(context) |
|||
.EntityMap.ForMember(dest => dest.ProductCount, opt => opt.MapFrom(src => src.Products.Count)); |
|||
``` |
|||
|
|||
## IMultiLingualObjectManager |
|||
|
|||
`IMultiLingualObjectManager` interface defines `GetTranslation` and `GetTranslationAsync` method to get the translation object of the entity. |
|||
|
|||
The default implementation of the `IMultiLingualObjectManager` interface finds the translation with selected UI language first. If there is no translation with selected UI language, then extension method searches for the default language setting (see [Setting](Settings.md)) and uses the translation in default language. If extension method couldn't find any translation in current UI language or default language, it uses one of the existing translations. |
|||
https://github.com/abpframework/abp/issues/1169 |
|||
|
|||
@ -1,3 +1,256 @@ |
|||
# Emailing |
|||
# 邮件发送系统 |
|||
|
|||
待添加 |
|||
ABP 框架为发送电子邮件提供各种服务、设置和集成; |
|||
|
|||
* 提供用于发送电子邮件的`IEmailSender`服务. |
|||
* 定义 [settings](Settings.md)来配置电子邮件发送. |
|||
* 集成到[后台作业系统](Background-Jobs.md)以通过后台作业发送电子邮件. |
|||
* 提供[MailKit 集成](MailKit.md)包. |
|||
|
|||
## 安装 |
|||
|
|||
> 如果你使用的是[应用程序启动模板](Startup-Templates/Application.md),则该软件包已安装。 |
|||
> |
|||
建议使用 [ABP CLI](CLI.md) 安装此包。在项目文件夹(.csproj 文件)中打开命令行窗口并键入以下命令: |
|||
|
|||
````bash |
|||
abp add-package Volo.Abp.Emailing |
|||
```` |
|||
如果你还没有做到这一点,你首先需要安装 ABP CLI。有关其他安装选项,请参阅 [包描述页面](https://abp.io/package-detail/Volo.Abp.Emailing)。 |
|||
|
|||
## 发送电子邮件 |
|||
|
|||
### IEmailSender |
|||
|
|||
[Inject](Dependency-Injection.md) 将 `IEmailSender` 注入任何服务并使用 `SendAsync` 方法发送电子邮件。 |
|||
**Example** |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Emailing; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
private readonly IEmailSender _emailSender; |
|||
|
|||
public MyService(IEmailSender emailSender) |
|||
{ |
|||
_emailSender = emailSender; |
|||
} |
|||
|
|||
public async Task DoItAsync() |
|||
{ |
|||
await _emailSender.SendAsync( |
|||
"target@domain.com", // target email address |
|||
"Email subject", // subject |
|||
"This is email body..." // email body |
|||
); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`SendAsync` 方法具有重载以提供更多参数,例如; |
|||
|
|||
* **from**: 你可以将其设置为设置发件人电子邮件地址。如果未提供,则使用默认发件人地址(请参阅下面的电子邮件设置) |
|||
* **isBodyHtml**: 表示邮件正文是否可以包含HTML标签。**默认:true**。 |
|||
|
|||
> `IEmailSender` 是建议的发送邮件的方式,因为它使你的代码提供者独立。 |
|||
|
|||
#### 邮件消息 |
|||
|
|||
除了原始参数之外,你还可以传递一个**标准的 `MailMessage` 对象**([参见](https://docs.microsoft.com/en-us/dotnet/api/system.net.mail.mailmessage) ) 到 `SendAsync` 方法以设置更多选项,例如添加附件。 |
|||
|
|||
### ISmtpEmailSender |
|||
|
|||
默认情况下,发送电子邮件由标准的 `SmtpClient` 类([参见](https://docs.microsoft.com/en-us/dotnet/api/system.net.mail.smtpclient))实现。实现类是`SmtpEmailSender`。此类还公开了 `ISmtpEmailSender` 服务(除了 `IEmailSender`)。 |
|||
|
|||
大多数时候你想直接使用`IEmailSender`来让你的代码提供者独立。但是,如果要创建具有相同电子邮件设置的 `SmtpClient` 对象,可以注入 `ISmtpEmailSender` 并使用其 `BuildClientAsync` 方法获取 `SmtpClient` 对象并自己发送电子邮件。 |
|||
|
|||
## 发送邮件任务队列/后台作业 |
|||
|
|||
`IEmailSender`有一个`QueueAsync`方法,可以用来将邮件添加到后台作业队列中,在后台线程中发送。通过这种方式,你不会因为等待发送邮件而占用用户的时间。`QueueAsync`方法得到的参数与`SendAsync`方法相同。 |
|||
|
|||
发送邮件任务队列可以容忍错误,因为后台作业系统具有重试机制来克服临时网络/服务器问题。 |
|||
|
|||
有关后台作业系统的更多信息,请参阅[后台作业文档](Background-Jobs.md)。 |
|||
|
|||
## 电子邮件设置 |
|||
|
|||
电子邮件发送使用 [设置系统](Settings.md) 来定义设置并在运行时获取这些设置的值。 `Volo.Abp.Emailing.EmailSettingNames` 定义了设置名称的常量,如下所示: |
|||
|
|||
* **Abp.Mailing.DefaultFromAddress**: 当你在发送电子邮件时未指定发件人时,用作发件人的电子邮件地址(就像上面的示例一样). |
|||
* **Abp.Mailing.DefaultFromDisplayName**: 当你在发送电子邮件时未指定发件人时,用作发件人的显示名称(就像在上面的示例中一样). |
|||
* **Abp.Mailing.Smtp.Host**: SMTP 服务器的 IP/域(默认值:127.0.0.1)。 |
|||
* **Abp.Mailing.Smtp.Port**: SMTP 服务器的端口(默认值:25). |
|||
* **Abp.Mailing.Smtp.UserName**: 用户名,如果 SMTP 服务器需要身份验证需要。 |
|||
* **Abp.Mailing.Smtp.Password**: 密码,如果 SMTP 服务器需要身份验证需要。 **此值已加密**(请参阅下面的部分). |
|||
* **Abp.Mailing.Smtp.Domain**: 账号域,如果 SMTP 服务器需要身份验证需要. |
|||
* **Abp.Mailing.Smtp.EnableSsl**: 指示 SMTP 服务器是否使用 SSL 的值(“true”或“false”。默认值:“false”). |
|||
* **Abp.Mailing.Smtp.UseDefaultCredentials**:如果为 true,则使用默认凭据,而不是提供的用户名和密码(“true”或“false”。默认值:“true”)。. |
|||
|
|||
可以从[设置管理](Modules/Setting-Management.md)模块的*设置页面*管理电子邮件设置: |
|||
|
|||
 |
|||
|
|||
>如果你已从 ABP 启动模板创建解决方案,则已安装设置管理模块。 |
|||
|
|||
如果你不使用设置管理模块,你可以简单地在 `appsettings.json` 文件中定义设置: |
|||
|
|||
````json |
|||
"Settings": { |
|||
"Abp.Mailing.Smtp.Host": "127.0.0.1", |
|||
"Abp.Mailing.Smtp.Port": "25", |
|||
"Abp.Mailing.Smtp.UserName": "", |
|||
"Abp.Mailing.Smtp.Password": "", |
|||
"Abp.Mailing.Smtp.Domain": "", |
|||
"Abp.Mailing.Smtp.EnableSsl": "false", |
|||
"Abp.Mailing.Smtp.UseDefaultCredentials": "true", |
|||
"Abp.Mailing.DefaultFromAddress": "noreply@abp.io", |
|||
"Abp.Mailing.DefaultFromDisplayName": "ABP application" |
|||
} |
|||
```` |
|||
|
|||
You can set/change these settings programmatically using the `ISettingManager` and store values in a database. See the [setting system document](Settings.md) to understand the setting system better. |
|||
你可以使用 `ISettingManager` 以编程方式设置/更改这些设置,并将值存储在数据库中。请参阅 [设置系统文档](Settings.md)更好地了解设置系统。 |
|||
|
|||
### 加密 SMTP 密码 |
|||
|
|||
*Abp.Mailing.Smtp.Password* 必须是一个**加密**值。如果你使用 `ISettingManager` 设置密码,你不必担心。它在内部加密 set 上的值并在 get 上解密。 |
|||
|
|||
如果使用 `appsettings.json` 存储密码,则应手动注入 `ISettingEncryptionService` 并使用其 `Encrypt` 方法获取加密值。这可以通过在你的应用程序中创建一个简单的代码来完成。然后你可以删除代码。更好的是,你可以在应用程序中创建一个 UI 来配置电子邮件设置。在这种情况下,你可以直接使用 `ISettingManager` 而不用担心加密。 |
|||
|
|||
### ISmtpEmailSenderConfiguration |
|||
|
|||
如果你不想使用设置系统来存储电子邮件发送配置,你可以将 `ISmtpEmailSenderConfiguration` 服务替换为你自己的实现,以从任何其他来源获取配置。 `ISmtpEmailSenderConfiguration` 默认由 `SmtpEmailSenderConfiguration` 实现,如上所述,它从设置系统中获取配置。 |
|||
|
|||
## 文本模板集成 |
|||
|
|||
ABP 框架提供了一个强大而灵活的[文本模板系统](Text-Templating.md)。你可以使用文本模板系统来创建动态电子邮件内容。注入 `ITemplateRenderer` 并使用 `RenderAsync` 渲染模板。然后将结果用作电子邮件正文。 |
|||
|
|||
虽然你可以定义和使用自己的文本模板,但电子邮件发送系统提供了两个简单的内置文本模板。 |
|||
|
|||
**示例:使用标准和简单的消息模板发送电子邮件** |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Emailing; |
|||
using Volo.Abp.Emailing.Templates; |
|||
using Volo.Abp.TextTemplating; |
|||
|
|||
namespace Acme.BookStore.Web |
|||
{ |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
private readonly IEmailSender _emailSender; |
|||
private readonly ITemplateRenderer _templateRenderer; |
|||
|
|||
public MyService( |
|||
IEmailSender emailSender, |
|||
ITemplateRenderer templateRenderer) |
|||
{ |
|||
_emailSender = emailSender; |
|||
_templateRenderer = templateRenderer; |
|||
} |
|||
|
|||
public async Task DoItAsync() |
|||
{ |
|||
var body = await _templateRenderer.RenderAsync( |
|||
StandardEmailTemplates.Message, |
|||
new |
|||
{ |
|||
message = "This is email body..." |
|||
} |
|||
); |
|||
|
|||
await _emailSender.SendAsync( |
|||
"target-address@domain.com", |
|||
"Email subject", |
|||
body |
|||
); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
生成的电子邮件正文将如下所示: |
|||
|
|||
````html |
|||
<!DOCTYPE html> |
|||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml"> |
|||
<head> |
|||
<meta charset="utf-8" /> |
|||
</head> |
|||
<body> |
|||
This is email body... |
|||
</body> |
|||
</html> |
|||
```` |
|||
|
|||
电子邮件系统定义了具有给定名称的内置文本模板: |
|||
|
|||
"**Abp.StandardEmailTemplates.Message**" 是最简单的带有文本消息的模板: |
|||
|
|||
````html |
|||
{%{{{model.message}}}%} |
|||
```` |
|||
|
|||
此模板使用“Abp.StandardEmailTemplates.Layout”作为其布局. |
|||
|
|||
"**Abp.StandardEmailTemplates.Layout**" 是一个提供 HTML 文档布局的简单模板: |
|||
|
|||
````html |
|||
<!DOCTYPE html> |
|||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml"> |
|||
<head> |
|||
<meta charset="utf-8" /> |
|||
</head> |
|||
<body> |
|||
{%{{{content}}}%} |
|||
</body> |
|||
</html> |
|||
```` |
|||
|
|||
最终呈现的消息如上所示。 |
|||
|
|||
> 这些模板名称是在 `Volo.Abp.Emailing.Templates.StandardEmailTemplates` 类中定义的。 |
|||
|
|||
### 覆盖/替换标准模板 |
|||
|
|||
You typically want to replace the standard templates with your own ones, so you can prepare a branded email messages. To do that, you can use the power of the [virtual file system](Virtual-File-System.md) (VFS) or replace them in your own template definition provider. |
|||
你希望用自己的模板替换标准模板,这样你就可以准备电子邮件模板文件。你可以使用 [虚拟文件系统](Virtual-File-System.md) (VFS),或在你自己的模板定义提供程序中替换它们。 |
|||
|
|||
虚拟文件系统中模板的路径如下图所示: |
|||
|
|||
* `/Volo/Abp/Emailing/Templates/Layout.tpl` |
|||
* `/Volo/Abp/Emailing/Templates/Message.tpl` |
|||
|
|||
如果你将文件添加到虚拟文件系统中的相同位置,你的文件将覆盖它们。 |
|||
|
|||
模板是内联本地化的,这意味着你可以利用 [本地化系统](Localization.md) 的强大功能使你的模板具有多元文化。 |
|||
|
|||
详见[文本模板系统](Text-Templating.md) 文档。 |
|||
|
|||
> 请注意,你可以为应用程序定义和使用自己的模板,而不是使用标准的简单模板。这些标准模板主要用于可重用的模块,它们不定义自己的模板,而是依赖内置的模板。只需覆盖标准的电子邮件布局模板,就可以轻松自定义使用的模块发送的电子邮件。 |
|||
|
|||
## NullEmailSender |
|||
|
|||
`NullEmailSender` 是实现 `IEmailSender` 的内置类,但将电子邮件内容写入 [标准日志系统](Logging.md),而不是实际发送电子邮件。 |
|||
|
|||
这个类特别有用,尤其是在你开发是不想发送真实电子邮件。 [应用启动模板](Startup-Templates/Application.md)已经在**DEBUG模式**中使用了这个类,在领域层配置如下: |
|||
|
|||
````csharp |
|||
#if DEBUG |
|||
context.Services.Replace(ServiceDescriptor.Singleton<IEmailSender, NullEmailSender>()); |
|||
#endif |
|||
```` |
|||
|
|||
因此,你在 DEBUG 模式下不会收到电子邮件。电子邮件将在生产时按预期发送(RELEASE 模式)。如果你也想在 DEBUG 上发送电子邮件,请删除这些行。 |
|||
|
|||
## 其他 |
|||
|
|||
* [用于发送电子邮件的MailKit集成](MailKit.md) |
|||
|
|||
@ -1 +1,48 @@ |
|||
TODO... |
|||
# MailKit 集成 |
|||
|
|||
[MailKit](http://www.mimekit.net/) 是一个用于 .net 的跨平台、流行的开源邮件客户端库.ABP 框架提供了一个集成包来使用 MailKit 作为[邮件发送系统](Emailing.md)的收发组件. |
|||
|
|||
## 安装 |
|||
|
|||
建议使用 [ABP CLI](CLI.md) 安装包.在项目文件夹(.csproj 文件)中打开命令行窗口并键入以下命令: |
|||
|
|||
````bash |
|||
abp add-package Volo.Abp.MailKit |
|||
```` |
|||
|
|||
如果执行失败,你首先需要安装 ABP CLI.有关其他安装选项,请参阅 [包描述页面](https://abp.io/package-detail/Volo.Abp.MailKit). |
|||
|
|||
## 发送电子邮件 |
|||
|
|||
### IEmailSender |
|||
|
|||
[注入](Dependency-Injection.md) 标准的 `IEmailSender` 到任何服务并使用 `SendAsync` 方法发送电子邮件.详见 [邮件发送文档](Emailing.md). |
|||
|
|||
> `IEmailSender` 是建议的发送邮件的方式,即使你使用MailKit,因为它使你的代码独立. |
|||
|
|||
### IMailKitSmtpEmailSender |
|||
|
|||
`BuildClientAsync()` 方法扩展了`IEmailSender`.此方法可用于获取可用于执行 MailKit 特定操作的`MailKit.Net.Smtp.SmtpClient`对象. |
|||
|
|||
## 配置 |
|||
|
|||
MailKit 集成包使用电子邮件发送系统相同配置选项.请参阅[电子邮件发送文档](Emailing.md) 进行配置. |
|||
|
|||
除了标准设置之外,这个包还定义了 `AbpMailKitOptions` 作为一个简单的 [选项](Options.md) 类.此类仅定义一个选项: |
|||
|
|||
* **SecureSocketOption**:用于设置“SecureSocketOptions” . Default:`null`(使用默认值) |
|||
|
|||
**示例: 使用 *SecureSocketOptions.SslOnConnect*** |
|||
|
|||
````csharp |
|||
Configure<AbpMailKitOptions>(options => |
|||
{ |
|||
options.SecureSocketOption = SecureSocketOptions.SslOnConnect; |
|||
}); |
|||
```` |
|||
|
|||
请参阅 [MailKit 文档](http://www.mimekit.net/) 了解更多信息. |
|||
|
|||
## 也可以看看 |
|||
|
|||
* [电子邮件发送系统](Emailing.md) |
|||
|
|||
@ -1 +1,40 @@ |
|||
TODO... |
|||
# 预览版 |
|||
|
|||
预览版在发布ABP 框架的主要版本或功能版本之前约 4 周发布。它们是为开发人员发布的,以尝试提供反馈以获得更稳定的版本。 |
|||
|
|||
预览版的版本控制是类似这样的: |
|||
|
|||
* 3.1.0-rc.1 |
|||
* 4.0.0-rc.1 |
|||
|
|||
在稳定版本(如 3.1.0)之前,可能会发布多个预览版本(如 3.1.0-rc.2 和 3.1.0-rc.3)。 |
|||
|
|||
## 使用预览版 |
|||
|
|||
### 新解决方案 |
|||
|
|||
要创建用于测试预览版的项目,您可以选择[下载页面](https://abp.io/get-started)上的“预览”选项或带有 **--preview** 参数的[ABP CLI](CLI.md) **abp new** 命令: |
|||
|
|||
````bash |
|||
abp new Acme.BookStore --preview |
|||
```` |
|||
|
|||
此命令将使用最新的预览版 NuGet 包、NPM 包和解决方案模板创建一个新项目。每当发布稳定版本时,您都可以使用解决方案abp switch-to-stable根文件夹中的命令切换到解决方案的稳定版本。 |
|||
|
|||
### 现有解决方案 |
|||
|
|||
如果您已有解决方案并希望使用/测试最新的预览版,请在解决方案的根文件夹中使用以下 [ABP CLI](CLI.md) 命令. |
|||
|
|||
````bash |
|||
abp switch-to-preview |
|||
```` |
|||
|
|||
您可以稍后使用 `abp switch-to-stable ` 命令返回最新的稳定版. |
|||
|
|||
````bash |
|||
abp switch-to-stable |
|||
```` |
|||
|
|||
## 提供反馈 |
|||
|
|||
如果您发现错误或想要提供任何类型的反馈,您可以在[GitHub 存储库](https://github.com/abpframework/abp/issues/new)上打开一个问题。 |
|||
|
|||
@ -1 +1,59 @@ |
|||
TODO... |
|||
# 升级 ABP 框架 |
|||
|
|||
本文档解释当ABP框架发布一个新版本时,怎样升级你已存在的解决方案. |
|||
|
|||
## ABP UPDATE 命令 |
|||
|
|||
ABP 框架 & 模块生态系统由数百个 NuGet 和 NPM 包组成. 手动更新所有这些软件包以升级您的应用程序会很麻烦。 |
|||
|
|||
[ABP CLI](CLI.md) 提供了一个方便的命令,可以使用单个命令更新解决方案中所有与 ABP 相关的 NuGet 和 NPM 包: |
|||
````bash |
|||
abp update |
|||
```` |
|||
在终端里,解决方案的根文件夹中运行此命令。 |
|||
|
|||
> 如果您的解决方案有 Angular UI,您的解决方案中可能有 `aspnet-core` 和 `angular` 文件夹。在这两个文件夹的父文件夹中运行此命令。 |
|||
|
|||
### 数据库迁移 |
|||
|
|||
> 警告:迁移数据库时要小心,因为在某些情况下可能会丢失数据。在执行之前仔细检查生成的迁移代码。建议备份您当前的数据库。 |
|||
|
|||
当您升级到新版本时,如果您的数据库提供者是 **Entity Framework Core**,最好检查是否有数据库架构更改并升级您的数据库架构; |
|||
|
|||
|
|||
* 在包管理器控制台 (PMC) 中使用 `Add-Migration "Upgraded_To_Abp_4_1"` 或类似命令创建新迁移(在 Visual Studio 中将 `EntityFrameworkCore` 设置为 PMC 中的默认项目,并将 `.DbMigrator` 在解决方案资源管理器中设置为启动项目)。 |
|||
* 运行 `.DbMigrator` 应用程序以升级数据库并播种初始数据。 |
|||
|
|||
如果 `Add-Migration` 生成一个空迁移,您可以在执行 `.DbMigrator` 之前使用 `Remove-Migration` 将其删除。 |
|||
|
|||
## 博客文章和指南 |
|||
|
|||
每当您升级您的解决方案时,强烈建议您查看 [ABP BLOG](https://blog.abp.io/) 以了解新版本的新功能和更改。我们会定期发布帖子并编写此类更改。 |
|||
|
|||
### 迁移指南 |
|||
|
|||
如果新版本为现有应用程序带来重大更改,我们会准备迁移指南。所有指南请参见 [迁移指南](Migration-Guides/Index.md) 页面。 |
|||
|
|||
### 升级 Startup 模板 |
|||
|
|||
有时我们会引入需要**在启动模板中进行更改**的新功能/更改。我们已经在新应用程序的启动模板中实现了更改。但是,在某些情况下,您需要手动对现有解决方案进行一些小的更改。 |
|||
|
|||
逐行记录必要的更改是不切实际的。在这种情况下,我们建议您创建一个示例解决方案,一个是现有版本,一个是新版本,并使用差异工具进行比较。您可以 [查看本指南](Migration-Guides/Upgrading-Startup-Template.md) 了解如何使用 WinMerge 应用程序进行操作。 |
|||
|
|||
## 语义版本控制和重大更改 |
|||
|
|||
我们正在努力保持语义版本控制规则,因此您不会对 3.1、3.2、3.3 等次要(功能)版本进行重大更改...... |
|||
|
|||
但是,在某些情况下,我们也可能会在功能版本中引入重大更改; |
|||
|
|||
* ABP 有许多集成包,有时集成库/框架会发布主要版本并进行重大更改。在这种情况下,我们会仔细检查这些更改并决定是否升级集成包。如果变更的影响比较小,我们会更新集成包,并在发布博文中说明变更。在这种情况下,如果您使用过此集成包,则应按照博文中的说明进行操作。如果更改可能会破坏许多应用程序并且不容易修复,我们决定等到下一个主要的 ABP 框架版本发布时升级。 |
|||
* 有时我们必须进行重大更改以修复主要错误或使用问题。在这种情况下,我们认为开发人员已经无法正确使用该功能,因此通过重大更改修复它没有问题。在这种情况下,该功能通常是很少使用的功能。同样,我们尝试将影响保持在最低限度。 |
|||
|
|||
## 预览版和每日构建 |
|||
|
|||
预览版和夜间构建可以帮助您在新的稳定版本之前尝试新功能并调整您的解决方案。 |
|||
|
|||
* [预览版](Previews.md) 通常在次要(功能)版本之前约 2 周发布(我们的次要版本开发周期约为 4 周)。 |
|||
* [每日构建](Nightly-Builds.md) 每天晚上(周末除外)从开发分支发布。这意味着您可以尝试前一天的开发。 |
|||
|
|||
请参阅他们的文档以了解有关此类版本的详细信息。 |
|||
|
|||
@ -0,0 +1,12 @@ |
|||
using Swashbuckle.AspNetCore.SwaggerGen; |
|||
using Volo.Abp.Swashbuckle; |
|||
|
|||
namespace Microsoft.Extensions.DependencyInjection; |
|||
|
|||
public static class AbpSwaggerGenOptionsExtensions |
|||
{ |
|||
public static void HideAbpEndpoints(this SwaggerGenOptions swaggerGenOptions) |
|||
{ |
|||
swaggerGenOptions.DocumentFilter<AbpSwashbuckleDocumentFilter>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Microsoft.OpenApi.Models; |
|||
using Swashbuckle.AspNetCore.SwaggerGen; |
|||
|
|||
namespace Volo.Abp.Swashbuckle; |
|||
|
|||
public class AbpSwashbuckleDocumentFilter : IDocumentFilter |
|||
{ |
|||
protected string[] ActionUrlPrefixes = new[] {"Volo.Abp"}; |
|||
|
|||
public virtual void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) |
|||
{ |
|||
var actionUrls = context.ApiDescriptions |
|||
.Select(apiDescription => apiDescription.ActionDescriptor) |
|||
.Where(actionDescriptor => !string.IsNullOrEmpty(actionDescriptor.DisplayName) && |
|||
ActionUrlPrefixes.Any(actionUrlPrefix => !actionDescriptor.DisplayName.Contains(actionUrlPrefix))) |
|||
.DistinctBy(actionDescriptor => actionDescriptor.AttributeRouteInfo?.Template) |
|||
.Select(actionDescriptor => actionDescriptor.AttributeRouteInfo?.Template.EnsureStartsWith('/')) |
|||
.Where(actionUrl => !string.IsNullOrEmpty(actionUrl)) |
|||
.ToList(); |
|||
|
|||
swaggerDoc |
|||
.Paths |
|||
.RemoveAll(path => !actionUrls.Contains(path.Key)); |
|||
} |
|||
} |
|||