mirror of https://github.com/abpframework/abp.git
committed by
GitHub
119 changed files with 2298 additions and 318 deletions
@ -0,0 +1,178 @@ |
|||
# How to Use the Azure Active Directory Authentication for MVC / Razor Page Applications |
|||
|
|||
## Introduction |
|||
|
|||
This post demonstrates how to integrate AzureAD to an ABP application that enables users to sign in using OAuth 2.0 with credentials from Azure Active Directory. |
|||
|
|||
Adding Azure Active Directory is pretty straightforward in ABP framework. Couple of configurations needs to be done correctly. |
|||
|
|||
There will be two samples of connections for better coverage; |
|||
|
|||
- **AddAzureAD** (Microsoft.AspNetCore.Authentication.AzureAD.UI package) |
|||
- **AddOpenIdConnect** (Default Microsoft.AspNetCore.Authentication.OpenIdConnect package) |
|||
|
|||
|
|||
|
|||
## Sample Code |
|||
|
|||
https://github.com/abpframework/abp-samples/tree/master/aspnet-core/BookStore-AzureAD |
|||
|
|||
|
|||
|
|||
## Setup |
|||
|
|||
Update your `appsettings.json` in your **.Web** application and add the following section filled with your AzureAD application settings. |
|||
|
|||
````xml |
|||
"AzureAd": { |
|||
"Instance": "https://login.microsoftonline.com/", |
|||
"TenantId": "<your-tenant-id", |
|||
"ClientId": "<your-client-id>", |
|||
"Domain": "domain.onmicrosoft.com", |
|||
"CallbackPath": "/signin-azuread-oidc" |
|||
} |
|||
```` |
|||
|
|||
|
|||
|
|||
## AddAzureAD |
|||
|
|||
#### **Update your `appsettings.json`** |
|||
|
|||
Install `Microsoft.AspNetCore.Authentication.AzureAD.UI` package to your **.Web** application. |
|||
|
|||
In your **.Web** application, add the following section filled with your AzureAD application settings. Modify `ConfigureAuthentication` method of your **BookStoreWebModule** with the following: |
|||
|
|||
````xml |
|||
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration) |
|||
{ |
|||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); |
|||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Add("sub", ClaimTypes.NameIdentifier); |
|||
context.Services.AddAuthentication() |
|||
.AddIdentityServerAuthentication(options => |
|||
{ |
|||
options.Authority = configuration["AuthServer:Authority"]; |
|||
options.RequireHttpsMetadata = false; |
|||
options.ApiName = "Acme.BookStore"; |
|||
}) |
|||
.AddAzureAD(options => configuration.Bind("AzureAd", options)); |
|||
|
|||
context.Services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options => |
|||
{ |
|||
options.Authority = options.Authority + "/v2.0/"; |
|||
options.ClientId = configuration["AzureAd:ClientId"]; |
|||
options.CallbackPath = configuration["AzureAd:CallbackPath"]; |
|||
options.ResponseType = OpenIdConnectResponseType.IdToken; |
|||
options.RequireHttpsMetadata = false; |
|||
|
|||
options.TokenValidationParameters.ValidateIssuer = false; |
|||
options.GetClaimsFromUserInfoEndpoint = true; |
|||
options.SaveTokens = true; |
|||
options.SignInScheme = IdentityConstants.ExternalScheme; |
|||
|
|||
options.Scope.Add("email"); |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
|
|||
|
|||
## AddOpenIdConnect |
|||
|
|||
Modify `ConfigureAuthentication` method of your **BookStoreWebModule** with the following: |
|||
|
|||
````xml |
|||
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration) |
|||
{ |
|||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); |
|||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Add("sub", ClaimTypes.NameIdentifier); |
|||
|
|||
context.Services.AddAuthentication() |
|||
.AddIdentityServerAuthentication(options => |
|||
{ |
|||
options.Authority = configuration["AuthServer:Authority"]; |
|||
options.RequireHttpsMetadata = false; |
|||
options.ApiName = "BookStore"; |
|||
}) |
|||
.AddOpenIdConnect("AzureOpenId", "AzureAD", options => |
|||
{ |
|||
options.Authority = "https://login.microsoftonline.com/" + configuration["AzureAd:TenantId"]; |
|||
options.ClientId = configuration["AzureAd:ClientId"]; |
|||
options.ResponseType = OpenIdConnectResponseType.CodeIdToken; |
|||
options.CallbackPath = configuration["AzureAd:CallbackPath"]; |
|||
options.RequireHttpsMetadata = false; |
|||
options.SaveTokens = true; |
|||
options.GetClaimsFromUserInfoEndpoint = true; |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
|
|||
|
|||
# FAQ |
|||
|
|||
* Help! `GetExternalLoginInfoAsync` returns `null`! |
|||
|
|||
* There can be 2 reasons for this; |
|||
|
|||
1. You are trying to authenticate against wrong scheme. Check if you set **SignInScheme** to `IdentityConstants.ExternalScheme`: |
|||
|
|||
````xml |
|||
options.SignInScheme = IdentityConstants.ExternalScheme; |
|||
```` |
|||
|
|||
2. Your `ClaimTypes.NameIdentifier` is `null`. Check if you added claim mapping: |
|||
|
|||
````xml |
|||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); |
|||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Add("sub", ClaimTypes.NameIdentifier); |
|||
```` |
|||
|
|||
|
|||
* Help! I keep getting ***AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application*** error! |
|||
|
|||
* If you set your **CallbackPath** in appsettings as: |
|||
|
|||
````xml |
|||
"AzureAd": { |
|||
... |
|||
"CallbackPath": "/signin-azuread-oidc" |
|||
} |
|||
```` |
|||
|
|||
your **Redirect URI** of your application in azure portal must be with <u>domain</u> like `https://localhost:44320/signin-azuread-oidc`, not only `/signin-azuread-oidc`. |
|||
|
|||
* Help! I am getting ***System.ArgumentNullException: Value cannot be null. (Parameter 'userName')*** error! |
|||
|
|||
|
|||
* This occurs when you use Azure Authority **v2.0 endpoint** without requesting `email` scope. [Abp checks unique email to create user](https://github.com/abpframework/abp/blob/037ef9abe024c03c1f89ab6c933710bcfe3f5c93/modules/account/src/Volo.Abp.Account.Web/Pages/Account/Login.cshtml.cs#L208). Simply add |
|||
|
|||
````xml |
|||
options.Scope.Add("email"); |
|||
```` |
|||
|
|||
to your openid configuration. |
|||
|
|||
* How can I **debug/watch** which claims I get before they get mapped? |
|||
|
|||
* You can add a simple event under openid configuration to debug before mapping like: |
|||
|
|||
````xml |
|||
options.Events.OnTokenValidated = (async context => |
|||
{ |
|||
var claimsFromOidcProvider = context.Principal.Claims.ToList(); |
|||
await Task.CompletedTask; |
|||
}); |
|||
```` |
|||
|
|||
* I want to debug further, how can I implement my custom **SignInManager**? |
|||
|
|||
* You can check [Customizing SignInManager in Abp Framework](link here) post. |
|||
|
|||
* I want to add extra properties to user while they are being created. How can I do that? |
|||
|
|||
* You can check [Customizing Login Page in Abp Framework]() post. |
|||
|
|||
* Why can't I see **External Register Page** after I sign in from external provider for the first time? |
|||
|
|||
* ABP framework automatically registers your user with supported email claim from your external authentication provider. You can change this behavior by [Customizing Login Page in Abp Framework](will be link here). |
|||
@ -0,0 +1,113 @@ |
|||
# How to Customize the Login Page for MVC / Razor Page Applications |
|||
|
|||
When you create a new application using the [application startup template](../Startup-Templates/Application.md), source code of the login page will not be inside your solution, so you can not directly change it. The login page comes from the [Account Module](../Modules/Account) that is used a [NuGet package](https://www.nuget.org/packages/Volo.Abp.Account.Web) reference. |
|||
|
|||
This document explains how to customize the login page for your own application. |
|||
|
|||
## Create a Login PageModel |
|||
|
|||
Create a new class inheriting from the [LoginModel](https://github.com/abpframework/abp/blob/037ef9abe024c03c1f89ab6c933710bcfe3f5c93/modules/account/src/Volo.Abp.Account.Web/Pages/Account/Login.cshtml.cs) of the Account module. |
|||
|
|||
````csharp |
|||
public class CustomLoginModel : LoginModel |
|||
{ |
|||
public CustomLoginModel( |
|||
Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider, |
|||
Microsoft.Extensions.Options.IOptions<Volo.Abp.Account.Web.AbpAccountOptions> accountOptions) |
|||
: base(schemeProvider, accountOptions) |
|||
{ |
|||
} |
|||
} |
|||
```` |
|||
|
|||
> Naming convention is important here. If your class name doesn't end with `LoginModel`, you need to manually replace the `LoginModel` using the [dependency injection](../Dependency-Injection.md) system. |
|||
|
|||
Then you can override any method you need and add new methods and properties needed by the UI. |
|||
|
|||
## Overriding the Login Page UI |
|||
|
|||
Create folder named **Account** under **Pages** directory and create a **Login.cshtml** under this folder. It will automatically override the `Login.cshtml` file defined in the Account Module thanks to the [Virtual File System](../Virtual-File-System.md). |
|||
|
|||
A good way to customize a page is to copy its source code. [Click here](https://github.com/abpframework/abp/blob/dev/modules/account/src/Volo.Abp.Account.Web/Pages/Account/Login.cshtml) for the source code of the login page. At the time this document has been written, the source code was like below: |
|||
|
|||
````xml |
|||
@page |
|||
@using Volo.Abp.Account.Settings |
|||
@using Volo.Abp.Settings |
|||
@model Acme.BookStore.Web.Pages.Account.CustomLoginModel |
|||
@inherits Volo.Abp.Account.Web.Pages.Account.AccountPage |
|||
@inject Volo.Abp.Settings.ISettingProvider SettingProvider |
|||
@if (Model.EnableLocalLogin) |
|||
{ |
|||
<div class="card mt-3 shadow-sm rounded"> |
|||
<div class="card-body p-5"> |
|||
<h4>@L["Login"]</h4> |
|||
@if (await SettingProvider.IsTrueAsync(AccountSettingNames.IsSelfRegistrationEnabled)) |
|||
{ |
|||
<strong> |
|||
@L["AreYouANewUser"] |
|||
<a href="@Url.Page("./Register", new {returnUrl = Model.ReturnUrl, returnUrlHash = Model.ReturnUrlHash})" class="text-decoration-none">@L["Register"]</a> |
|||
</strong> |
|||
} |
|||
<form method="post" class="mt-4"> |
|||
<input asp-for="ReturnUrl" /> |
|||
<input asp-for="ReturnUrlHash" /> |
|||
<div class="form-group"> |
|||
<label asp-for="LoginInput.UserNameOrEmailAddress"></label> |
|||
<input asp-for="LoginInput.UserNameOrEmailAddress" class="form-control" /> |
|||
<span asp-validation-for="LoginInput.UserNameOrEmailAddress" class="text-danger"></span> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label asp-for="LoginInput.Password"></label> |
|||
<input asp-for="LoginInput.Password" class="form-control" /> |
|||
<span asp-validation-for="LoginInput.Password" class="text-danger"></span> |
|||
</div> |
|||
<div class="form-check"> |
|||
<label asp-for="LoginInput.RememberMe" class="form-check-label"> |
|||
<input asp-for="LoginInput.RememberMe" class="form-check-input" /> |
|||
@Html.DisplayNameFor(m => m.LoginInput.RememberMe) |
|||
</label> |
|||
</div> |
|||
<abp-button type="submit" button-type="Primary" name="Action" value="Login" class="btn-block btn-lg mt-3">@L["Login"]</abp-button> |
|||
</form> |
|||
</div> |
|||
|
|||
<div class="card-footer text-center border-0"> |
|||
<abp-button type="button" button-type="Link" name="Action" value="Cancel" class="px-2 py-0">@L["Cancel"]</abp-button> @* TODO: Only show if identity server is used *@ |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
@if (Model.VisibleExternalProviders.Any()) |
|||
{ |
|||
<div class="col-md-6"> |
|||
<h4>@L["UseAnotherServiceToLogIn"]</h4> |
|||
<form asp-page="./Login" asp-page-handler="ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" asp-route-returnUrlHash="@Model.ReturnUrlHash" method="post"> |
|||
<input asp-for="ReturnUrl" /> |
|||
<input asp-for="ReturnUrlHash" /> |
|||
@foreach (var provider in Model.VisibleExternalProviders) |
|||
{ |
|||
<button type="submit" class="btn btn-primary" name="provider" value="@provider.AuthenticationScheme" title="@L["GivenTenantIsNotAvailable", provider.DisplayName]">@provider.DisplayName</button> |
|||
} |
|||
</form> |
|||
</div> |
|||
} |
|||
|
|||
@if (!Model.EnableLocalLogin && !Model.VisibleExternalProviders.Any()) |
|||
{ |
|||
<div class="alert alert-warning"> |
|||
<strong>@L["InvalidLoginRequest"]</strong> |
|||
@L["ThereAreNoLoginSchemesConfiguredForThisClient"] |
|||
</div> |
|||
} |
|||
```` |
|||
|
|||
Just changed the `@model` to `Acme.BookStore.Web.Pages.Account.CustomLoginModel` to use the customized `PageModel` class. You can change it however your application needs. |
|||
|
|||
## The Source Code |
|||
|
|||
You can find the source code of the completed example [here](https://github.com/abpframework/abp-samples/tree/master/aspnet-core/Authentication-Customization). |
|||
|
|||
## See Also |
|||
|
|||
* [ASP.NET Core (MVC / Razor Pages) User Interface Customization Guide](../UI/AspNetCore/Customization-User-Interface). |
|||
@ -0,0 +1,84 @@ |
|||
# Customize the SignInManager |
|||
|
|||
## Introduction |
|||
|
|||
ABP Framework uses Microsoft Identity underneath hence supports customization as much as Microsoft Identity does. |
|||
|
|||
## Sample Code |
|||
|
|||
https://github.com/abpframework/abp-samples/blob/master/aspnet-core/BookStore-AzureAD/src/Acme.BookStore.Web/CustomSignInManager.cs |
|||
|
|||
## Creating CustomSignInManager |
|||
|
|||
To create your own custom SignIn Manager, you need to inherit `SignInManager<Volo.Abp.Identity.IdentityUser>`. |
|||
|
|||
````xml |
|||
public class CustomSignInManager : SignInManager<Volo.Abp.Identity.IdentityUser> |
|||
{ |
|||
public CustomSigninManager( |
|||
UserManager<Volo.Abp.Identity.IdentityUser> userManager, |
|||
IHttpContextAccessor contextAccessor, |
|||
IUserClaimsPrincipalFactory<Volo.Abp.Identity.IdentityUser> claimsFactory, |
|||
IOptions<IdentityOptions> optionsAccessor, |
|||
ILogger<SignInManager<Volo.Abp.Identity.IdentityUser>> logger, |
|||
IAuthenticationSchemeProvider schemes, |
|||
IUserConfirmation<Volo.Abp.Identity.IdentityUser> confirmation) |
|||
: base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) |
|||
{ |
|||
} |
|||
} |
|||
```` |
|||
|
|||
|
|||
|
|||
## Overriding Methods |
|||
|
|||
Afterwards you can override a method like `GetExternalLoginInfoAsync`: |
|||
|
|||
````xml |
|||
public override async Task<ExternalLoginInfo> GetExternalLoginInfoAsync(string expectedXsrf = null) |
|||
{ |
|||
var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme); |
|||
var items = auth?.Properties?.Items; |
|||
if (auth?.Principal == null || items == null || !items.ContainsKey("LoginProvider")) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (expectedXsrf != null) |
|||
{ |
|||
if (!items.ContainsKey("XsrfId")) |
|||
{ |
|||
return null; |
|||
} |
|||
var userId = items[XsrfKey] as string; |
|||
if (userId != expectedXsrf) |
|||
{ |
|||
return null; |
|||
} |
|||
var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier); |
|||
var provider = items[LoginProviderKey] as string; |
|||
if (providerKey == null || provider == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName ?? provider; |
|||
return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) |
|||
{ |
|||
AuthenticationTokens = auth.Properties.GetTokens() |
|||
}; |
|||
} |
|||
```` |
|||
|
|||
|
|||
|
|||
## Registering to DI |
|||
|
|||
You need to register your Custom SignIn Manager to DI to activate it. Inside the `.Web` project, locate the `ApplicationNameWebModule` and add the following under `ConfigureServices` method: |
|||
|
|||
````xml |
|||
context.Services |
|||
.GetObject<IdentityBuilder>() |
|||
.AddSignInManager<CustomSigninManager>(); |
|||
```` |
|||
@ -0,0 +1,9 @@ |
|||
# "How To" Guides |
|||
|
|||
This section contains "how to" guides for some specific questions frequently asked. While some of them are common development tasks and not directly related to the ABP Framework, we think it is useful to have some concrete examples those directly work with your ABP based applications. |
|||
|
|||
## Authentication |
|||
|
|||
* [How to Customize the Login Page for MVC / Razor Page Applications](Customize-Login-Page-MVC.md) |
|||
* [How to Use the Azure Active Directory Authentication for MVC / Razor Page Applications](Azure-Active-Directory-Authentication-MVC.md) |
|||
* [Customize the SignInManager](Customize-SignIn-Manager.md) (as an example of customization) |
|||
@ -0,0 +1,56 @@ |
|||
# ContentSecurityStrategy |
|||
|
|||
`ContentSecurityStrategy` is an abstract class exposed by @abp/ng.core package. It helps you mark inline scripts or styles as safe in terms of [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy). |
|||
|
|||
|
|||
|
|||
|
|||
## API |
|||
|
|||
|
|||
### constructor(public nonce?: string) |
|||
|
|||
- `nonce` enables whitelisting inline script or styles in order to avoid using `unsafe-inline` in [script-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script) and [style-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src#Unsafe_inline_styles) directives. |
|||
|
|||
|
|||
### applyCSP(element: HTMLScriptElement | HTMLStyleElement): void |
|||
|
|||
This method maps the aforementioned properties to the given `element`. |
|||
|
|||
|
|||
|
|||
|
|||
## LooseContentSecurityPolicy |
|||
|
|||
`LooseContentSecurityPolicy` is a class that extends `ContentSecurityStrategy`. It requires `nonce` and marks given `<script>` or `<style>` tag with it. |
|||
|
|||
|
|||
|
|||
|
|||
## StrictContentSecurityPolicy |
|||
|
|||
`StrictContentSecurityPolicy` is a class that extends `ContentSecurityStrategy`. It does not mark inline scripts and styles as safe. You can consider it as a noop alternative. |
|||
|
|||
|
|||
|
|||
|
|||
## Predefined Content Security Strategies |
|||
|
|||
Predefined content security strategies are accessible via `CONTENT_SECURITY_STRATEGY` constant. |
|||
|
|||
|
|||
### Loose(nonce: string) |
|||
|
|||
`nonce` will be set. |
|||
|
|||
|
|||
### Strict() |
|||
|
|||
Nothing will be done. |
|||
|
|||
|
|||
|
|||
|
|||
## What's Next? |
|||
|
|||
TODO: Place new InsertionStrategy link here. |
|||
@ -0,0 +1,41 @@ |
|||
# CrossOriginStrategy |
|||
|
|||
`CrossOriginStrategy` is a class exposed by @abp/ng.core package. Its instances define how a source referenced by an element will be retrieved by the browser and are consumed by other classes such as `LoadingStrategy`. |
|||
|
|||
|
|||
## API |
|||
|
|||
|
|||
### constructor(public crossorigin: 'anonymous' | 'use-credentials', public integrity?: string) |
|||
|
|||
- `crossorigin` is mapped to [the HTML attribute with the same name](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin). |
|||
- `integrity` is a hash for validating a remote resource. Its use is explained [here](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity). |
|||
|
|||
|
|||
### setCrossOrigin(element: HTMLElement): void |
|||
|
|||
This method maps the aforementioned properties to the given `element`. |
|||
|
|||
|
|||
|
|||
|
|||
## Predefined Cross-Origin Strategies |
|||
|
|||
Predefined cross-origin strategies are accessible via `CROSS_ORIGIN_STRATEGY` constant. |
|||
|
|||
|
|||
### Anonymous(integrity?: string) |
|||
|
|||
`crossorigin` will be set as `"anonymous"` and `integrity` is optional. |
|||
|
|||
|
|||
### UseCredentials(integrity?: string) |
|||
|
|||
`crossorigin` will be set as `"use-credentials"` and `integrity` is optional. |
|||
|
|||
|
|||
|
|||
|
|||
## What's Next? |
|||
|
|||
- [LoadingStrategy](./Loading-Strategy.md) |
|||
@ -0,0 +1,58 @@ |
|||
# DomStrategy |
|||
|
|||
`DomStrategy` is a class exposed by @abp/ng.core package. Its instances define how an element will be attached to the DOM and are consumed by other classes such as `LoadingStrategy`. |
|||
|
|||
|
|||
## API |
|||
|
|||
|
|||
### constructor(public target?: HTMLElement, public position?: InsertPosition) |
|||
|
|||
- `target` is an HTMLElement (_default: document.head_). |
|||
- `position` defines where the created element will be placed. All possible values of `position` can be found [here](https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement) (_default: 'beforeend'_). |
|||
|
|||
|
|||
### insertElement(element: HTMLElement): void |
|||
|
|||
This method inserts given `element` to `target` based on the `position`. |
|||
|
|||
|
|||
|
|||
|
|||
## Predefined Dom Strategies |
|||
|
|||
Predefined dom strategies are accessible via `DOM_STRATEGY` constant. |
|||
|
|||
|
|||
### AppendToBody() |
|||
|
|||
`insertElement` will place the given `element` at the end of `<body>`. |
|||
|
|||
|
|||
### AppendToHead() |
|||
|
|||
`insertElement` will place the given `element` at the end of `<head>`. |
|||
|
|||
|
|||
### PrependToHead() |
|||
|
|||
`insertElement` will place the given `element` at the beginning of `<head>`. |
|||
|
|||
|
|||
### AfterElement(target: HTMLElement) |
|||
|
|||
`insertElement` will place the given `element` after (as a sibling to) the `target`. |
|||
|
|||
|
|||
### BeforeElement(target: HTMLElement) |
|||
|
|||
`insertElement` will place the given `element` before (as a sibling to) the `target`. |
|||
|
|||
|
|||
|
|||
|
|||
## What's Next? |
|||
|
|||
- [LoadingStrategy](./Loading-Strategy.md) |
|||
|
|||
TODO: Place new InsertionStrategy link here. |
|||
@ -0,0 +1,205 @@ |
|||
# How to Lazy Load Scripts and Styles |
|||
|
|||
You can use the `LazyLoadService` in @abp/ng.core package in order to lazy load scripts and styles in an easy and explicit way. |
|||
|
|||
|
|||
|
|||
|
|||
## Getting Started |
|||
|
|||
You do not have to provide the `LazyLoadService` at module or component level, because it is already **provided in root**. You can inject and start using it immediately in your components, directives, or services. |
|||
|
|||
```js |
|||
import { LazyLoadService } from '@abp/ng.core'; |
|||
|
|||
@Component({ |
|||
/* class metadata here */ |
|||
}) |
|||
class DemoComponent { |
|||
constructor(private lazyLoadService: LazyLoadService) {} |
|||
} |
|||
``` |
|||
|
|||
|
|||
|
|||
|
|||
## Usage |
|||
|
|||
You can use the `load` method of `LazyLoadService` to create a `<script>` or `<link>` element in the DOM at the desired position and force the browser to download the target resource. |
|||
|
|||
|
|||
|
|||
### How to Load Scripts |
|||
|
|||
The first parameter of `load` method expects a `LoadingStrategy`. If you pass a `ScriptLoadingStrategy` instance, the `LazyLoadService` will create a `<script>` element with given `src` and place it in the designated DOM position. |
|||
|
|||
```js |
|||
import { LazyLoadService, LOADING_STRATEGY } from '@abp/ng.core'; |
|||
|
|||
@Component({ |
|||
template: ` |
|||
<some-component *ngIf="libraryLoaded$ | async"></some-component> |
|||
` |
|||
}) |
|||
class DemoComponent { |
|||
libraryLoaded$ = this.lazyLoad.load( |
|||
LOADING_STRATEGY.AppendAnonymousScriptToHead('/assets/some-library.js'), |
|||
); |
|||
|
|||
constructor(private lazyLoadService: LazyLoadService) {} |
|||
} |
|||
``` |
|||
|
|||
The `load` method returns an observable to which you can subscibe in your component or with an `async` pipe. In the example above, the `NgIf` directive will render `<some-component>` only **if the script gets successfully loaded or is already loaded before**. |
|||
|
|||
> You can subscribe multiple times in your template with `async` pipe. The styles will only be loaded once. |
|||
|
|||
Please refer to [LoadingStrategy](./Loading-Strategy.md) to see all available loading strategies and how you can build your own loading strategy. |
|||
|
|||
|
|||
|
|||
### How to Load Styles |
|||
|
|||
If you pass a `StyleLoadingStrategy` instance as the first parameter of `load` method, the `LazyLoadService` will create a `<link>` element with given `href` and place it in the designated DOM position. |
|||
|
|||
```js |
|||
import { LazyLoadService, LOADING_STRATEGY } from '@abp/ng.core'; |
|||
|
|||
@Component({ |
|||
template: ` |
|||
<some-component *ngIf="stylesLoaded$ | async"></some-component> |
|||
` |
|||
}) |
|||
class DemoComponent { |
|||
stylesLoaded$ = this.lazyLoad.load( |
|||
LOADING_STRATEGY.AppendAnonymousStyleToHead('/assets/some-styles.css'), |
|||
); |
|||
|
|||
constructor(private lazyLoadService: LazyLoadService) {} |
|||
} |
|||
``` |
|||
|
|||
The `load` method returns an observable to which you can subscibe in your component or with an `AsyncPipe`. In the example above, the `NgIf` directive will render `<some-component>` only **if the style gets successfully loaded or is already loaded before**. |
|||
|
|||
> You can subscribe multiple times in your template with `async` pipe. The styles will only be loaded once. |
|||
|
|||
Please refer to [LoadingStrategy](./Loading-Strategy.md) to see all available loading strategies and how you can build your own loading strategy. |
|||
|
|||
|
|||
|
|||
### Advanced Usage |
|||
|
|||
You have quite a bit of **freedom to define how your lazy load will work**. Here is an example: |
|||
|
|||
```js |
|||
const domStrategy = DOM_STRATEGY.PrependToHead(); |
|||
|
|||
const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous( |
|||
'sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh', |
|||
); |
|||
|
|||
const loadingStrategy = new StyleLoadingStrategy( |
|||
'https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css', |
|||
domStrategy, |
|||
crossOriginStrategy, |
|||
); |
|||
|
|||
this.lazyLoad.load(loadingStrategy, 1, 2000); |
|||
``` |
|||
|
|||
This code will create a `<link>` element with given url and integrity hash, insert it to to top of the `<head>` element, and retry once after 2 seconds if first try fails. |
|||
|
|||
|
|||
A common usecase is **loading multiple scripts and/or styles before using a feature**: |
|||
|
|||
```js |
|||
import { LazyLoadService, LOADING_STRATEGY } from '@abp/ng.core'; |
|||
import { frokJoin } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
template: ` |
|||
<some-component *ngIf="scriptsAndStylesLoaded$ | async"></some-component> |
|||
` |
|||
}) |
|||
class DemoComponent { |
|||
private stylesLoaded$ = forkJoin( |
|||
this.lazyLoad.load( |
|||
LOADING_STRATEGY.PrependAnonymousStyleToHead('/assets/library-dark-theme.css'), |
|||
), |
|||
this.lazyLoad.load( |
|||
LOADING_STRATEGY.PrependAnonymousStyleToHead('/assets/library.css'), |
|||
), |
|||
); |
|||
|
|||
private scriptsLoaded$ = forkJoin( |
|||
this.lazyLoad.load( |
|||
LOADING_STRATEGY.AppendAnonymousScriptToHead('/assets/library.js'), |
|||
), |
|||
this.lazyLoad.load( |
|||
LOADING_STRATEGY.AppendAnonymousScriptToHead('/assets/other-library.css'), |
|||
), |
|||
); |
|||
|
|||
scriptsAndStylesLoaded$ = forkJoin(this.scriptsLoaded$, this.stylesLoaded$); |
|||
|
|||
constructor(private lazyLoadService: LazyLoadService) {} |
|||
} |
|||
``` |
|||
|
|||
RxJS `forkJoin` will load all scripts and styles in parallel and emit only when all of them are loaded. So, when `<some-component>` is placed, all required dependencies will be available. |
|||
|
|||
> Noticed we have prepended styles to the document head? This is sometimes necessary, because your application styles may be overriding some of the library styles. In such a case, you must be careful about the order of prepended styles. They will be placed one-by-one and, **when prepending, the last one placed will be on top**. |
|||
|
|||
|
|||
Another frequent usecase is **loading dependent scripts in order**: |
|||
|
|||
```js |
|||
import { LazyLoadService, LOADING_STRATEGY } from '@abp/ng.core'; |
|||
import { concat } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
template: ` |
|||
<some-component *ngIf="scriptsLoaded$ | async"></some-component> |
|||
` |
|||
}) |
|||
class DemoComponent { |
|||
scriptsLoaded$ = concat( |
|||
this.lazyLoad.load( |
|||
LOADING_STRATEGY.PrependAnonymousScriptToHead('/assets/library.js'), |
|||
), |
|||
this.lazyLoad.load( |
|||
LOADING_STRATEGY.AppendAnonymousScriptToHead('/assets/script-that-requires-library.js'), |
|||
), |
|||
); |
|||
|
|||
constructor(private lazyLoadService: LazyLoadService) {} |
|||
} |
|||
``` |
|||
|
|||
In this example, the second file needs the first one to be loaded beforehand. RxJS `concat` function will let you load all scripts one-by-one in the given order and emit only when all of them are loaded. |
|||
|
|||
|
|||
|
|||
|
|||
## API |
|||
|
|||
|
|||
|
|||
### loaded: Set<string> |
|||
|
|||
All previously loaded paths are available via this property. It is a simple [JavaScript Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set). |
|||
|
|||
|
|||
|
|||
### load(strategy: LoadingStrategy, retryTimes?: number, retryDelay?: number): Observable<Event> |
|||
|
|||
- `strategy` parameter is the primary focus here and is explained above. |
|||
- `retryTimes` defines how many times the loading will be tried again before fail (_default: 2_). |
|||
- `retryDelay` defines how much delay there will be between retries (_default: 1000_). |
|||
|
|||
|
|||
|
|||
|
|||
## What's Next? |
|||
|
|||
- [TrackByService](./Track-By-Service.md) |
|||
@ -0,0 +1,77 @@ |
|||
# LoadingStrategy |
|||
|
|||
`LoadingStrategy` is an abstract class exposed by @abp/ng.core package. There are two loading strategies extending it: `ScriptLoadingStrategy` and `StyleLoadingStrategy`. Implementing the same methods and properties, both of these strategies help you define how your lazy loading will work. |
|||
|
|||
|
|||
|
|||
|
|||
## API |
|||
|
|||
|
|||
### constructor(public path: string, protected domStrategy?: DomStrategy, protected crossOriginStrategy?: CrossOriginStrategy) |
|||
|
|||
- `path` is set to `<script>` elements as `src` and `<link>` elements as `href` attribute. |
|||
- `domStrategy` is the `DomStrategy` that will be used when inserting the created element. (_default: AppendToHead_) |
|||
- `crossOriginStrategy` is the `CrossOriginStrategy` that will be used on the created element before inserting it. (_default: Anonymous_) |
|||
|
|||
Please refer to [DomStrategy](./Dom-Strategy.md) and [CrossOriginStrategy](./Cross-Origin-Strategy.md) documentation for their usage. |
|||
|
|||
|
|||
### createElement(): HTMLScriptElement | HTMLLinkElement |
|||
|
|||
This method creates and returns a `<script>` or `<link>` element with `path` set as `src` or `href`. |
|||
|
|||
|
|||
### createStream(): Observable<Event> |
|||
|
|||
This method creates and returns an observable stream that emits on success and throws on error. |
|||
|
|||
|
|||
|
|||
## ScriptLoadingStrategy |
|||
|
|||
`ScriptLoadingStrategy` is a class that extends `LoadingStrategy`. It lets you **lazy load a script**. |
|||
|
|||
|
|||
|
|||
## StyleLoadingStrategy |
|||
|
|||
`StyleLoadingStrategy` is a class that extends `LoadingStrategy`. It lets you **lazy load a style**. |
|||
|
|||
|
|||
|
|||
## Predefined Loading Strategies |
|||
|
|||
Predefined content security strategies are accessible via `LOADING_STRATEGY` constant. |
|||
|
|||
|
|||
### AppendAnonymousScriptToHead(src: string, integrity?: string) |
|||
|
|||
Sets given paremeters and `crossorigin="anonymous"` as attributes of created `<script>` element and places it at the **end** of `<head>` tag in the document. |
|||
|
|||
|
|||
### PrependAnonymousScriptToHead(src: string, integrity?: string) |
|||
|
|||
Sets given paremeters and `crossorigin="anonymous"` as attributes of created `<script>` element and places it at the **beginning** of `<head>` tag in the document. |
|||
|
|||
|
|||
### AppendAnonymousScriptToBody(src: string, integrity?: string) |
|||
|
|||
Sets given paremeters and `crossorigin="anonymous"` as attributes of created `<script>` element and places it at the **end** of `<body>` tag in the document. |
|||
|
|||
|
|||
### AppendAnonymousStyleToHead(href: string, integrity?: string) |
|||
|
|||
Sets given paremeters and `crossorigin="anonymous"` as attributes of created `<style>` element and places it at the **end** of `<head>` tag in the document. |
|||
|
|||
|
|||
### PrependAnonymousStyleToHead(href: string, integrity?: string) |
|||
|
|||
Sets given paremeters and `crossorigin="anonymous"` as attributes of created `<style>` element and places it at the **beginning** of `<head>` tag in the document. |
|||
|
|||
|
|||
|
|||
|
|||
## What's Next? |
|||
|
|||
- [LazyLoadService](./Lazy-Load-Service.md) |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"extends": "../../tsconfig.json", |
|||
"compilerOptions": { |
|||
"outDir": "../../out-tsc/app", |
|||
"types": [] |
|||
}, |
|||
"files": ["src/main.ts", "src/polyfills.ts"], |
|||
"include": ["src/**/*.ts"], |
|||
"exclude": ["src/test.ts", "src/**/*.spec.ts"] |
|||
} |
|||
@ -1,5 +1,5 @@ |
|||
{ |
|||
"extends": "../../tsconfig.json", |
|||
"extends": "../../tsconfig.prod.json", |
|||
"compilerOptions": { |
|||
"outDir": "../../out-tsc/app", |
|||
"types": [], |
|||
@ -0,0 +1,32 @@ |
|||
export abstract class ContentSecurityStrategy { |
|||
constructor(public nonce?: string) {} |
|||
|
|||
abstract applyCSP(element: HTMLScriptElement | HTMLStyleElement): void; |
|||
} |
|||
|
|||
export class LooseContentSecurityStrategy extends ContentSecurityStrategy { |
|||
constructor(nonce: string) { |
|||
super(nonce); |
|||
} |
|||
|
|||
applyCSP(element: HTMLScriptElement | HTMLStyleElement) { |
|||
element.setAttribute('nonce', this.nonce); |
|||
} |
|||
} |
|||
|
|||
export class StrictContentSecurityStrategy extends ContentSecurityStrategy { |
|||
constructor() { |
|||
super(); |
|||
} |
|||
|
|||
applyCSP(_: HTMLScriptElement | HTMLStyleElement) {} |
|||
} |
|||
|
|||
export const CONTENT_SECURITY_STRATEGY = { |
|||
Loose(nonce: string) { |
|||
return new LooseContentSecurityStrategy(nonce); |
|||
}, |
|||
Strict() { |
|||
return new StrictContentSecurityStrategy(); |
|||
}, |
|||
}; |
|||
@ -0,0 +1,17 @@ |
|||
export class CrossOriginStrategy { |
|||
constructor(public crossorigin: 'anonymous' | 'use-credentials', public integrity?: string) {} |
|||
|
|||
setCrossOrigin<T extends HTMLElement>(element: T) { |
|||
if (this.integrity) element.setAttribute('integrity', this.integrity); |
|||
element.setAttribute('crossorigin', this.crossorigin); |
|||
} |
|||
} |
|||
|
|||
export const CROSS_ORIGIN_STRATEGY = { |
|||
Anonymous(integrity?: string) { |
|||
return new CrossOriginStrategy('anonymous', integrity); |
|||
}, |
|||
UseCredentials(integrity?: string) { |
|||
return new CrossOriginStrategy('use-credentials', integrity); |
|||
}, |
|||
}; |
|||
@ -0,0 +1,28 @@ |
|||
export class DomStrategy { |
|||
constructor( |
|||
public target: HTMLElement = document.head, |
|||
public position: InsertPosition = 'beforeend', |
|||
) {} |
|||
|
|||
insertElement<T extends HTMLElement>(element: T) { |
|||
this.target.insertAdjacentElement(this.position, element); |
|||
} |
|||
} |
|||
|
|||
export const DOM_STRATEGY = { |
|||
AfterElement(element: HTMLElement) { |
|||
return new DomStrategy(element, 'afterend'); |
|||
}, |
|||
AppendToBody() { |
|||
return new DomStrategy(document.body, 'beforeend'); |
|||
}, |
|||
AppendToHead() { |
|||
return new DomStrategy(document.head, 'beforeend'); |
|||
}, |
|||
BeforeElement(element: HTMLElement) { |
|||
return new DomStrategy(element, 'beforebegin'); |
|||
}, |
|||
PrependToHead() { |
|||
return new DomStrategy(document.head, 'afterbegin'); |
|||
}, |
|||
}; |
|||
@ -0,0 +1,4 @@ |
|||
export * from './content-security.strategy'; |
|||
export * from './cross-origin.strategy'; |
|||
export * from './dom.strategy'; |
|||
export * from './loading.strategy'; |
|||
@ -0,0 +1,88 @@ |
|||
import { Observable, of } from 'rxjs'; |
|||
import { switchMap } from 'rxjs/operators'; |
|||
import { fromLazyLoad } from '../utils'; |
|||
import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from './cross-origin.strategy'; |
|||
import { DomStrategy, DOM_STRATEGY } from './dom.strategy'; |
|||
|
|||
export abstract class LoadingStrategy<T extends HTMLScriptElement | HTMLLinkElement = any> { |
|||
constructor( |
|||
public path: string, |
|||
protected domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), |
|||
protected crossOriginStrategy: CrossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous(), |
|||
) {} |
|||
|
|||
abstract createElement(): T; |
|||
|
|||
createStream<E extends Event>(): Observable<E> { |
|||
return of(null).pipe( |
|||
switchMap(() => |
|||
fromLazyLoad<E>(this.createElement(), this.domStrategy, this.crossOriginStrategy), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
|
|||
export class ScriptLoadingStrategy extends LoadingStrategy<HTMLScriptElement> { |
|||
constructor(src: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { |
|||
super(src, domStrategy, crossOriginStrategy); |
|||
} |
|||
|
|||
createElement(): HTMLScriptElement { |
|||
const element = document.createElement('script'); |
|||
element.src = this.path; |
|||
|
|||
return element; |
|||
} |
|||
} |
|||
|
|||
export class StyleLoadingStrategy extends LoadingStrategy<HTMLLinkElement> { |
|||
constructor(href: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { |
|||
super(href, domStrategy, crossOriginStrategy); |
|||
} |
|||
|
|||
createElement(): HTMLLinkElement { |
|||
const element = document.createElement('link'); |
|||
element.rel = 'stylesheet'; |
|||
element.href = this.path; |
|||
|
|||
return element; |
|||
} |
|||
} |
|||
|
|||
export const LOADING_STRATEGY = { |
|||
AppendAnonymousScriptToBody(src: string, integrity?: string) { |
|||
return new ScriptLoadingStrategy( |
|||
src, |
|||
DOM_STRATEGY.AppendToBody(), |
|||
CROSS_ORIGIN_STRATEGY.Anonymous(integrity), |
|||
); |
|||
}, |
|||
AppendAnonymousScriptToHead(src: string, integrity?: string) { |
|||
return new ScriptLoadingStrategy( |
|||
src, |
|||
DOM_STRATEGY.AppendToHead(), |
|||
CROSS_ORIGIN_STRATEGY.Anonymous(integrity), |
|||
); |
|||
}, |
|||
AppendAnonymousStyleToHead(src: string, integrity?: string) { |
|||
return new StyleLoadingStrategy( |
|||
src, |
|||
DOM_STRATEGY.AppendToHead(), |
|||
CROSS_ORIGIN_STRATEGY.Anonymous(integrity), |
|||
); |
|||
}, |
|||
PrependAnonymousScriptToHead(src: string, integrity?: string) { |
|||
return new ScriptLoadingStrategy( |
|||
src, |
|||
DOM_STRATEGY.PrependToHead(), |
|||
CROSS_ORIGIN_STRATEGY.Anonymous(integrity), |
|||
); |
|||
}, |
|||
PrependAnonymousStyleToHead(src: string, integrity?: string) { |
|||
return new StyleLoadingStrategy( |
|||
src, |
|||
DOM_STRATEGY.PrependToHead(), |
|||
CROSS_ORIGIN_STRATEGY.Anonymous(integrity), |
|||
); |
|||
}, |
|||
}; |
|||
@ -0,0 +1,41 @@ |
|||
import { |
|||
CONTENT_SECURITY_STRATEGY, |
|||
LooseContentSecurityStrategy, |
|||
StrictContentSecurityStrategy, |
|||
} from '../strategies'; |
|||
import { uuid } from '../utils'; |
|||
|
|||
describe('LooseContentSecurityStrategy', () => { |
|||
describe('#applyCSP', () => { |
|||
it('should set nonce attribute', () => { |
|||
const nonce = uuid(); |
|||
const strategy = new LooseContentSecurityStrategy(nonce); |
|||
const element = document.createElement('link'); |
|||
strategy.applyCSP(element); |
|||
|
|||
expect(element.getAttribute('nonce')).toBe(nonce); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('StrictContentSecurityStrategy', () => { |
|||
describe('#applyCSP', () => { |
|||
it('should not set nonce attribute', () => { |
|||
const strategy = new StrictContentSecurityStrategy(); |
|||
const element = document.createElement('link'); |
|||
strategy.applyCSP(element); |
|||
|
|||
expect(element.getAttribute('nonce')).toBeNull(); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('CONTENT_SECURITY_STRATEGY', () => { |
|||
test.each` |
|||
name | Strategy | nonce |
|||
${'Loose'} | ${LooseContentSecurityStrategy} | ${uuid()} |
|||
${'Strict'} | ${StrictContentSecurityStrategy} | ${undefined} |
|||
`('should successfully map $name to $Strategy.name', ({ name, Strategy, nonce }) => {
|
|||
expect(CONTENT_SECURITY_STRATEGY[name](nonce)).toEqual(new Strategy(nonce)); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,38 @@ |
|||
import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies'; |
|||
import { uuid } from '../utils'; |
|||
|
|||
describe('CrossOriginStrategy', () => { |
|||
describe('#setCrossOrigin', () => { |
|||
it('should set crossorigin attribute', () => { |
|||
const strategy = new CrossOriginStrategy('use-credentials'); |
|||
const element = document.createElement('link'); |
|||
strategy.setCrossOrigin(element); |
|||
|
|||
expect(element.crossOrigin).toBe('use-credentials'); |
|||
}); |
|||
|
|||
it('should set integrity attribute when given', () => { |
|||
const integrity = uuid(); |
|||
const strategy = new CrossOriginStrategy('anonymous', integrity); |
|||
const element = document.createElement('link'); |
|||
strategy.setCrossOrigin(element); |
|||
|
|||
expect(element.crossOrigin).toBe('anonymous'); |
|||
expect(element.getAttribute('integrity')).toBe(integrity); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('CROSS_ORIGIN_STRATEGY', () => { |
|||
test.each` |
|||
name | integrity | crossOrigin |
|||
${'Anonymous'} | ${undefined} | ${'anonymous'} |
|||
${'Anonymous'} | ${uuid()} | ${'anonymous'} |
|||
${'UseCredentials'} | ${undefined} | ${'use-credentials'} |
|||
${'UseCredentials'} | ${uuid()} | ${'use-credentials'} |
|||
`('should successfully map $name to CrossOriginStrategy', ({ name, integrity, crossOrigin }) => {
|
|||
expect(CROSS_ORIGIN_STRATEGY[name](integrity)).toEqual( |
|||
new CrossOriginStrategy(crossOrigin, integrity), |
|||
); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,49 @@ |
|||
import { DomStrategy, DOM_STRATEGY } from '../strategies'; |
|||
|
|||
describe('DomStrategy', () => { |
|||
describe('#insertElement', () => { |
|||
it('should append element to head by default', () => { |
|||
const strategy = new DomStrategy(); |
|||
const element = document.createElement('script'); |
|||
strategy.insertElement(element); |
|||
|
|||
expect(document.head.lastChild).toBe(element); |
|||
}); |
|||
|
|||
it('should append element to body when body is given as target', () => { |
|||
const strategy = new DomStrategy(document.body); |
|||
const element = document.createElement('script'); |
|||
strategy.insertElement(element); |
|||
|
|||
expect(document.body.lastChild).toBe(element); |
|||
}); |
|||
|
|||
it('should prepend to head when position is given as "afterbegin"', () => { |
|||
const strategy = new DomStrategy(undefined, 'afterbegin'); |
|||
const element = document.createElement('script'); |
|||
strategy.insertElement(element); |
|||
|
|||
expect(document.head.firstChild).toBe(element); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('DOM_STRATEGY', () => { |
|||
const div = document.createElement('DIV'); |
|||
|
|||
beforeEach(() => { |
|||
document.body.innerHTML = ''; |
|||
document.body.appendChild(div); |
|||
}); |
|||
|
|||
test.each` |
|||
name | target | position |
|||
${'AfterElement'} | ${div} | ${'afterend'} |
|||
${'AppendToBody'} | ${document.body} | ${'beforeend'} |
|||
${'AppendToHead'} | ${document.head} | ${'beforeend'} |
|||
${'BeforeElement'} | ${div} | ${'beforebegin'} |
|||
${'PrependToHead'} | ${document.head} | ${'afterbegin'} |
|||
`('should successfully map $name to CrossOriginStrategy', ({ name, target, position }) => {
|
|||
expect(DOM_STRATEGY[name](target)).toEqual(new DomStrategy(target, position)); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,113 @@ |
|||
import { DomStrategy, DOM_STRATEGY } from '../strategies'; |
|||
import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; |
|||
import { uuid } from '../utils'; |
|||
import { fromLazyLoad } from '../utils/lazy-load-utils'; |
|||
|
|||
describe('Lazy Load Utils', () => { |
|||
describe('#fromLazyLoad', () => { |
|||
afterEach(() => { |
|||
jest.clearAllMocks(); |
|||
}); |
|||
|
|||
it('should append to head by default', () => { |
|||
const element = document.createElement('link'); |
|||
const spy = jest.spyOn(document.head, 'insertAdjacentElement'); |
|||
|
|||
fromLazyLoad(element); |
|||
expect(spy).toHaveBeenCalledWith('beforeend', element); |
|||
}); |
|||
|
|||
it('should allow setting a dom strategy', () => { |
|||
const element = document.createElement('link'); |
|||
const spy = jest.spyOn(document.head, 'insertAdjacentElement'); |
|||
|
|||
fromLazyLoad(element, DOM_STRATEGY.PrependToHead()); |
|||
expect(spy).toHaveBeenCalledWith('afterbegin', element); |
|||
}); |
|||
|
|||
it('should set crossorigin to "anonymous" by default', () => { |
|||
const element = document.createElement('link'); |
|||
|
|||
fromLazyLoad(element); |
|||
|
|||
expect(element.crossOrigin).toBe('anonymous'); |
|||
}); |
|||
|
|||
it('should not set integrity by default', () => { |
|||
const element = document.createElement('link'); |
|||
|
|||
fromLazyLoad(element); |
|||
|
|||
expect(element.getAttribute('integrity')).toBeNull(); |
|||
}); |
|||
|
|||
it('should allow setting a cross-origin strategy', () => { |
|||
const element = document.createElement('link'); |
|||
|
|||
const integrity = uuid(); |
|||
|
|||
fromLazyLoad(element, undefined, CROSS_ORIGIN_STRATEGY.UseCredentials(integrity)); |
|||
|
|||
expect(element.crossOrigin).toBe('use-credentials'); |
|||
expect(element.getAttribute('integrity')).toBe(integrity); |
|||
}); |
|||
|
|||
it('should emit error event on fail and clear callbacks', done => { |
|||
const error = new CustomEvent('error'); |
|||
const parentNode = { removeChild: jest.fn() }; |
|||
const element = ({ parentNode } as any) as HTMLLinkElement; |
|||
|
|||
fromLazyLoad( |
|||
element, |
|||
{ |
|||
insertElement(el: HTMLLinkElement) { |
|||
expect(el).toBe(element); |
|||
|
|||
setTimeout(() => { |
|||
el.onerror(error); |
|||
}, 0); |
|||
}, |
|||
} as DomStrategy, |
|||
{ |
|||
setCrossOrigin(_: HTMLLinkElement) {}, |
|||
} as CrossOriginStrategy, |
|||
).subscribe({ |
|||
error: value => { |
|||
expect(value).toBe(error); |
|||
expect(parentNode.removeChild).toHaveBeenCalledWith(element); |
|||
expect(element.onerror).toBeNull(); |
|||
done(); |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
it('should emit load event on success and clear callbacks', done => { |
|||
const success = new CustomEvent('load'); |
|||
const parentNode = { removeChild: jest.fn() }; |
|||
const element = ({ parentNode } as any) as HTMLLinkElement; |
|||
|
|||
fromLazyLoad( |
|||
element, |
|||
{ |
|||
insertElement(el: HTMLLinkElement) { |
|||
expect(el).toBe(element); |
|||
|
|||
setTimeout(() => { |
|||
el.onload(success); |
|||
}, 0); |
|||
}, |
|||
} as DomStrategy, |
|||
{ |
|||
setCrossOrigin(_: HTMLLinkElement) {}, |
|||
} as CrossOriginStrategy, |
|||
).subscribe({ |
|||
next: value => { |
|||
expect(value).toBe(success); |
|||
expect(parentNode.removeChild).not.toHaveBeenCalled(); |
|||
expect(element.onload).toBeNull(); |
|||
done(); |
|||
}, |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,102 @@ |
|||
import { |
|||
CROSS_ORIGIN_STRATEGY, |
|||
DOM_STRATEGY, |
|||
LOADING_STRATEGY, |
|||
ScriptLoadingStrategy, |
|||
StyleLoadingStrategy, |
|||
} from '../strategies'; |
|||
|
|||
const path = 'http://example.com/'; |
|||
|
|||
describe('ScriptLoadingStrategy', () => { |
|||
describe('#createElement', () => { |
|||
it('should return a script element with src attribute', () => { |
|||
const strategy = new ScriptLoadingStrategy(path); |
|||
const element = strategy.createElement(); |
|||
|
|||
expect(element.tagName).toBe('SCRIPT'); |
|||
expect(element.src).toBe(path); |
|||
}); |
|||
}); |
|||
|
|||
describe('#createStream', () => { |
|||
it('should use given dom and cross-origin strategies', done => { |
|||
const domStrategy = DOM_STRATEGY.PrependToHead(); |
|||
const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); |
|||
|
|||
domStrategy.insertElement = jest.fn((el: HTMLScriptElement) => { |
|||
setTimeout(() => { |
|||
el.onload( |
|||
new CustomEvent('success', { |
|||
detail: { |
|||
crossOrigin: el.crossOrigin, |
|||
}, |
|||
}), |
|||
); |
|||
}, 0); |
|||
}) as any; |
|||
|
|||
const strategy = new ScriptLoadingStrategy(path, domStrategy, crossOriginStrategy); |
|||
|
|||
strategy.createStream<CustomEvent>().subscribe(event => { |
|||
expect(event.detail.crossOrigin).toBe('use-credentials'); |
|||
done(); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('StyleLoadingStrategy', () => { |
|||
describe('#createElement', () => { |
|||
it('should return a style element with href and rel attributes', () => { |
|||
const strategy = new StyleLoadingStrategy(path); |
|||
const element = strategy.createElement(); |
|||
|
|||
expect(element.tagName).toBe('LINK'); |
|||
expect(element.href).toBe(path); |
|||
expect(element.rel).toBe('stylesheet'); |
|||
}); |
|||
}); |
|||
|
|||
describe('#createStream', () => { |
|||
it('should use given dom and cross-origin strategies', done => { |
|||
const domStrategy = DOM_STRATEGY.PrependToHead(); |
|||
const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); |
|||
|
|||
domStrategy.insertElement = jest.fn((el: HTMLLinkElement) => { |
|||
setTimeout(() => { |
|||
el.onload( |
|||
new CustomEvent('success', { |
|||
detail: { |
|||
crossOrigin: el.crossOrigin, |
|||
}, |
|||
}), |
|||
); |
|||
}, 0); |
|||
}) as any; |
|||
|
|||
const strategy = new StyleLoadingStrategy(path, domStrategy, crossOriginStrategy); |
|||
|
|||
strategy.createStream<CustomEvent>().subscribe(event => { |
|||
expect(event.detail.crossOrigin).toBe('use-credentials'); |
|||
done(); |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('LOADING_STRATEGY', () => { |
|||
test.each` |
|||
name | Strategy | domStrategy |
|||
${'AppendAnonymousScriptToBody'} | ${ScriptLoadingStrategy} | ${'AppendToBody'} |
|||
${'AppendAnonymousScriptToHead'} | ${ScriptLoadingStrategy} | ${'AppendToHead'} |
|||
${'AppendAnonymousStyleToHead'} | ${StyleLoadingStrategy} | ${'AppendToHead'} |
|||
${'PrependAnonymousScriptToHead'} | ${ScriptLoadingStrategy} | ${'PrependToHead'} |
|||
${'PrependAnonymousStyleToHead'} | ${StyleLoadingStrategy} | ${'PrependToHead'} |
|||
`(
|
|||
'should successfully map $name to $Strategy.name with $domStrategy dom strategy', |
|||
({ name, Strategy, domStrategy }) => { |
|||
expect(LOADING_STRATEGY[name](path)).toEqual(new Strategy(path, DOM_STRATEGY[domStrategy]())); |
|||
}, |
|||
); |
|||
}); |
|||
@ -1,5 +1,6 @@ |
|||
export * from './common-utils'; |
|||
export * from './generator-utils'; |
|||
export * from './initial-utils'; |
|||
export * from './lazy-load-utils'; |
|||
export * from './route-utils'; |
|||
export * from './rxjs-utils'; |
|||
|
|||
@ -0,0 +1,51 @@ |
|||
import { Observable, Observer } from 'rxjs'; |
|||
import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; |
|||
import { DomStrategy, DOM_STRATEGY } from '../strategies/dom.strategy'; |
|||
|
|||
export function fromLazyLoad<T extends Event>( |
|||
element: HTMLScriptElement | HTMLLinkElement, |
|||
domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), |
|||
crossOriginStrategy: CrossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous(), |
|||
): Observable<T> { |
|||
crossOriginStrategy.setCrossOrigin(element); |
|||
domStrategy.insertElement(element); |
|||
|
|||
return new Observable((observer: Observer<T>) => { |
|||
element.onload = (event: T) => { |
|||
clearCallbacks(element); |
|||
observer.next(event); |
|||
observer.complete(); |
|||
}; |
|||
|
|||
const handleError = createErrorHandler(observer, element); |
|||
|
|||
element.onerror = handleError; |
|||
element.onabort = handleError; |
|||
element.onemptied = handleError; |
|||
element.onstalled = handleError; |
|||
element.onsuspend = handleError; |
|||
|
|||
return () => { |
|||
clearCallbacks(element); |
|||
observer.complete(); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
function createErrorHandler(observer: Observer<Event>, element: HTMLElement) { |
|||
/* tslint:disable-next-line:only-arrow-functions */ |
|||
return function(event: Event | string) { |
|||
clearCallbacks(element); |
|||
element.parentNode.removeChild(element); |
|||
observer.error(event); |
|||
}; |
|||
} |
|||
|
|||
function clearCallbacks(element: HTMLElement) { |
|||
element.onload = null; |
|||
element.onerror = null; |
|||
element.onabort = null; |
|||
element.onemptied = null; |
|||
element.onstalled = null; |
|||
element.onsuspend = null; |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
{ |
|||
"compileOnSave": false, |
|||
"compilerOptions": { |
|||
"baseUrl": "./", |
|||
"outDir": "./dist/out-tsc", |
|||
"sourceMap": true, |
|||
"declaration": false, |
|||
"downlevelIteration": true, |
|||
"experimentalDecorators": true, |
|||
"module": "esnext", |
|||
"moduleResolution": "node", |
|||
"importHelpers": true, |
|||
"target": "es2015", |
|||
"typeRoots": ["node_modules/@types"], |
|||
"lib": ["es2018", "dom"], |
|||
"types": ["jest"] |
|||
}, |
|||
"angularCompilerOptions": { |
|||
"fullTemplateTypeCheck": true, |
|||
"strictInjectionParameters": true |
|||
} |
|||
} |
|||
@ -1,7 +1,7 @@ |
|||
module.exports = { |
|||
mappings: { |
|||
"@node_modules/to-mark/dist/to-mark.min.js": "@libs/to-mark/", |
|||
"@node_modules/tui-code-snippet/dist/tui-code-snippet.min.js": "@libs/tui-code-snippet/", |
|||
"@node_modules/tui-code-snippet/dist/*.*": "@libs/tui-code-snippet/", |
|||
"@node_modules/squire-rte/build/squire.js": "@libs/squire-rte/", |
|||
"@node_modules/tui-editor/dist/*.*": "@libs/tui-editor/" |
|||
} |
|||
|
|||
@ -0,0 +1,37 @@ |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.ObjectExtending; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Acme.BookStore.EntityFrameworkCore |
|||
{ |
|||
public static class BookStoreEfCoreEntityExtensionMappings |
|||
{ |
|||
private static readonly OneTimeRunner OneTimeRunner = new OneTimeRunner(); |
|||
|
|||
public static void Configure() |
|||
{ |
|||
OneTimeRunner.Run(() => |
|||
{ |
|||
/* You can configure entity extension properties for the |
|||
* entities defined in the used modules. |
|||
* |
|||
* The properties defined here becomes table fields. |
|||
* If you want to use the ExtraProperties dictionary of the entity |
|||
* instead of creating a new field, then define the property in the |
|||
* MyProjectNameDomainObjectExtensions class. |
|||
* |
|||
* Example: |
|||
* |
|||
* ObjectExtensionManager.Instance |
|||
* .MapEfCoreProperty<IdentityUser, string>( |
|||
* "MyProperty", |
|||
* b => b.HasMaxLength(128) |
|||
* ); |
|||
* |
|||
* See the documentation for more: |
|||
* https://docs.abp.io/en/abp/latest/Customizing-Application-Modules-Extending-Entities
|
|||
*/ |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
using Volo.Abp.ObjectExtending; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace Acme.BookStore.BookManagement.EntityFrameworkCore |
|||
{ |
|||
public static class BookManagementEfCoreEntityExtensionMappings |
|||
{ |
|||
private static readonly OneTimeRunner OneTimeRunner = new OneTimeRunner(); |
|||
|
|||
public static void Configure() |
|||
{ |
|||
OneTimeRunner.Run(() => |
|||
{ |
|||
/* You can configure entity extension properties for the |
|||
* entities defined in the used modules. |
|||
* |
|||
* The properties defined here becomes table fields. |
|||
* If you want to use the ExtraProperties dictionary of the entity |
|||
* instead of creating a new field, then define the property in the |
|||
* MyProjectNameDomainObjectExtensions class. |
|||
* |
|||
* Example: |
|||
* |
|||
* ObjectExtensionManager.Instance |
|||
* .MapEfCoreProperty<IdentityUser, string>( |
|||
* "MyProperty", |
|||
* b => b.HasMaxLength(128) |
|||
* ); |
|||
* |
|||
* See the documentation for more: |
|||
* https://docs.abp.io/en/abp/latest/Customizing-Application-Modules-Extending-Entities
|
|||
*/ |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue