Skip to content

Commit

Permalink
Implement new API to associate types with interface names.
Browse files Browse the repository at this point in the history
  • Loading branch information
mbeckem committed Oct 30, 2023
1 parent ea63ab2 commit 8e86e0e
Show file tree
Hide file tree
Showing 17 changed files with 276 additions and 44 deletions.
6 changes: 3 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ fi
echo '--- checking for consistent dependencies across packages'
pnpm lint-shared-versions

echo '--- run linting --- '
pnpm lint

echo '--- run prettier ---'
pnpm prettier-check

echo '--- run linting --- '
pnpm lint

echo '--- run typescript check ---'
pnpm check-types

Expand Down
7 changes: 3 additions & 4 deletions src/packages/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
import { DeclaredService } from "@open-pioneer/runtime";

/**
* Central service for sending HTTP requests.
*
* Use the interface `"http.HttpService"` to obtain an instance of this service.
*/
export interface HttpService {
export interface HttpService extends DeclaredService<"http.HttpService"> {
/**
* Requests the given `resource` via HTTP and returns the response.
*
Expand All @@ -21,12 +22,10 @@ export interface HttpService {
fetch(resource: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}

// TODO: Remove block with next major
import "@open-pioneer/runtime";
declare module "@open-pioneer/runtime" {
interface ServiceRegistry {
"http.HttpService": HttpService;
}
}

// Get rid of empty chunk warning
export default undefined;
14 changes: 10 additions & 4 deletions src/packages/integration/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
import { type ApiExtension, type ApiMethods, type ApiMethod } from "@open-pioneer/runtime";
import {
type ApiExtension,
type ApiMethods,
type ApiMethod,
DeclaredService
} from "@open-pioneer/runtime";

export { ApiExtension, ApiMethod, ApiMethods }; // re-export for consistency

/**
* Emits events to users of the current web component.
*
* Use the interface `"integration.ExternalEventService"` to obtain an instance of this service.
*/
export interface ExternalEventService {
export interface ExternalEventService extends DeclaredService<"integration.ExternalEventService"> {
/**
* Emits an event to the host site as a [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent).
*
Expand Down Expand Up @@ -46,8 +53,7 @@ export interface ExternalEventService {
emitEvent(event: Event): void;
}

export { ApiExtension, ApiMethod, ApiMethods }; // re-export for consistency

// TODO: Remove block with next major
import "@open-pioneer/runtime";
declare module "@open-pioneer/runtime" {
interface ServiceRegistry {
Expand Down
47 changes: 47 additions & 0 deletions src/packages/runtime/DeclaredService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
/* eslint-disable unused-imports/no-unused-vars */
import { DeclaredService, InterfaceNameForServiceType } from "./DeclaredService";

// Tests are on type level only
it("dummy test to allow a file without any real tests", () => undefined);

/**
* Returns type `true` if types A and B are equal (type false otherwise).
* See here: https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650
*/
// prettier-ignore
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;

/**
* Returns type `true` if T is any kind of string, `false` otherwise.
*/
type IsString<T> = T extends string ? true : false;

// Expect all strings are allowed when service type is unknown
{
type IFace = InterfaceNameForServiceType<unknown>;
const isString: Equal<IFace, string> = true;
}

// Expect only the declared interface name is allowed when an explicit service is provided
{
interface MyService extends DeclaredService<"my.service"> {
foo(): void;
}

type IFace = InterfaceNameForServiceType<MyService>;
const isConstant: Equal<IFace, "my.service"> = true;
}

// Expect an error is returned when an explicit type is used that does not extend DeclaredService
{
interface MyService {
foo(): void;
}

type IFace = InterfaceNameForServiceType<MyService>;
const isString: IsString<IFace> = false;
}
65 changes: 65 additions & 0 deletions src/packages/runtime/DeclaredService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
declare const INTERNAL_ASSOCIATED_SERVICE_METADATA: unique symbol;
declare const ERROR: unique symbol;

/**
* Base interface for services that are associated with a well known interface name.
*
* By using this base interface, you can ensure that users of your interface use the correct interface name.
*
* @example
* ```ts
* // MyLogger should be referenced via "my-package.Logger"
* export interface MyLogger extends DeclaredService<"my-package.Logger"> {
* log(message: string): void;
* }
* ```
*
* > Note: TypeScript may list the `INTERNAL_ASSOCIATED_SERVICE_METADATA` property
* > when generating the implementation for an interface extending this type.
* > You can simply remove the offending line; it is not required (and not possible)
* > to implement that attribute - it only exists for the compiler.
*/
export interface DeclaredService<InterfaceName extends string> {
/**
* Internal type-level service metadata.
*
* Note: there is no need to implement this symbol attribute.
* It is optional and only exists for the compiler, never at runtime.
*
* @internal
*/
[INTERNAL_ASSOCIATED_SERVICE_METADATA]?: ServiceMetadata<InterfaceName>;
}

/**
* Given a type implementing {@link DeclaredService}, this type will produce the interface name associated with the service type.
*/
export type AssociatedInterfaceName<T extends DeclaredService<string>> = T extends DeclaredService<
infer InterfaceName
>
? InterfaceName
: never;

/**
* This helper type produces the expected `interfaceName` (a string parameter) for the given service type.
*
* 1. If `ServiceType` is `unknown`, it will produce `string` to allow arbitrary parameters.
* 2. If `ServiceType` implements {@link DeclaredService}, it will enforce the associated interface name.
* 3. Otherwise, a compile time error is generated.
*/
export type InterfaceNameForServiceType<ServiceType> = unknown extends ServiceType
? string
: ServiceType extends DeclaredService<string>
? AssociatedInterfaceName<ServiceType>
: {
[ERROR]: "TypeScript integration was not set up properly for this service. Make sure the service's TypeScript interface extends 'DeclaredService'.";
};

/**
* @internal
*/
interface ServiceMetadata<InterfaceName> {
interfaceName: InterfaceName;
}
13 changes: 12 additions & 1 deletion src/packages/runtime/ServiceRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0

// eslint-disable-next-line unused-imports/no-unused-imports
import { DeclaredService } from "./DeclaredService";
/**
* Maps a registered interface name to a service type.
* The interface can be reopened by client packages to add additional registrations.
Expand All @@ -16,16 +17,26 @@
* }
* }
* ```
*
* @deprecated The global service registry is deprecated. Use {@link DeclaredService} in your service interface instead.
*
*/
// TODO: Remove with next major
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServiceRegistry {}

/**
* A well known interface name registered with the {@link ServiceRegistry}.
*
* @deprecated The global service registry is deprecated. Use {@link DeclaredService} in your service interface instead.
*/
// TODO: Remove with next major
export type InterfaceName = keyof ServiceRegistry;

/**
* Returns the registered service type for the given interface name.
*
* @deprecated The global service registry is deprecated. Use {@link DeclaredService} in your service interface instead.
*/
// TODO: Remove with next major
export type ServiceType<I extends InterfaceName> = ServiceRegistry[I];
9 changes: 6 additions & 3 deletions src/packages/runtime/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
import { DeclaredService } from "./DeclaredService";

/* eslint-disable @typescript-eslint/no-explicit-any */
export type ApiMethod = (...args: any[]) => any;
Expand All @@ -23,7 +24,7 @@ export interface ApiExtension {
* A service provided by the system.
* Used by the runtime to assemble the public facing API.
*/
export interface ApiService {
export interface ApiService extends DeclaredService<"runtime.ApiService"> {
/**
* Called by the runtime to gather methods that should be available from the web component's API.
*/
Expand All @@ -33,7 +34,7 @@ export interface ApiService {
/**
* A service provided by the system, useful for accessing values that are global to the application.
*/
export interface ApplicationContext {
export interface ApplicationContext extends DeclaredService<"runtime.ApplicationContext"> {
/**
* The web component's host element.
* This dom node can be accessed by the host site.
Expand Down Expand Up @@ -73,7 +74,8 @@ export interface ApplicationContext {
* **Experimental**. This interface is not affected by semver guarantees.
* It may change (or be removed) in a future minor release.
*/
export interface ApplicationLifecycleListener {
export interface ApplicationLifecycleListener
extends DeclaredService<"runtime.ApplicationLifecycleListener"> {
/**
* Called after all services required by the application have been started.
*/
Expand All @@ -85,6 +87,7 @@ export interface ApplicationLifecycleListener {
beforeApplicationStop?(): void;
}

// TODO: Remove block with next major
declare module "./ServiceRegistry" {
interface ServiceRegistry {
"runtime.ApiService": ApiService;
Expand Down
5 changes: 5 additions & 0 deletions src/packages/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ export {
export * from "./Service";
export * from "./ServiceRegistry";
export * from "./PropertiesRegistry";
export {
type DeclaredService,
type AssociatedInterfaceName,
type InterfaceNameForServiceType
} from "./DeclaredService";
3 changes: 2 additions & 1 deletion src/samples/api-sample/api-app/DemoUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { useService } from "open-pioneer:react-hooks";
import { TextService } from "./TextService";
import { Button, Container, VStack, Text, Heading } from "@open-pioneer/chakra-integration";
import { useEffect, useState } from "react";
import { ExternalEventService } from "@open-pioneer/integration";

export function DemoUI() {
const eventService = useService("integration.ExternalEventService");
const eventService = useService<ExternalEventService>("integration.ExternalEventService");
const emitEvent = () => {
eventService.emitEvent("my-custom-event", {
data: "my-event-data"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
import { Service, ServiceOptions, ServiceType } from "@open-pioneer/runtime";
import { Action, ActionService } from "./api";
import { Service, ServiceOptions } from "@open-pioneer/runtime";
import { Action, ActionProvider, ActionService } from "./api";

interface References {
providers: ServiceType<"extension-app.ActionProvider">[];
providers: ActionProvider[];
}

export class ActionServiceImpl implements Service<ActionService> {
Expand Down
3 changes: 2 additions & 1 deletion src/samples/extension-sample/extension-app/ActionsUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
import { Button, Container, Heading, Text, VStack } from "@open-pioneer/chakra-integration";
import { useService } from "open-pioneer:react-hooks";
import { ActionService } from "./api";

export function ActionsUI() {
const service = useService("extension-app.ActionService");
const service = useService<ActionService>("extension-app.ActionService");
const buttons = service.getActionInfo().map(({ id, text }) => (
<Button key={id} onClick={() => service.triggerAction(id)}>
{text}
Expand Down
13 changes: 3 additions & 10 deletions src/samples/extension-sample/extension-app/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: con terra GmbH and contributors
// SPDX-License-Identifier: Apache-2.0
import { DeclaredService } from "@open-pioneer/runtime";

/**
* Represents an action that can be triggered by the user.
Expand All @@ -18,7 +19,7 @@ export interface Action {
/**
* Provides actions to the {@link ActionService}.
*/
export interface ActionProvider {
export interface ActionProvider extends DeclaredService<"extension-app.ActionProvider"> {
/**
* Called by the {@link ActionService} to gather registered actions.
*
Expand All @@ -32,7 +33,7 @@ export interface ActionProvider {
*
* Implement the interface `"extension-app.ActionProvider"` to provide additional actions.
*/
export interface ActionService {
export interface ActionService extends DeclaredService<"extension-app.ActionService"> {
/**
* Returns the rendering information for all registered actions.
*/
Expand All @@ -43,11 +44,3 @@ export interface ActionService {
*/
triggerAction(id: string): void;
}

import "@open-pioneer/runtime";
declare module "@open-pioneer/runtime" {
interface ServiceRegistry {
"extension-app.ActionProvider": ActionProvider;
"extension-app.ActionService": ActionService;
}
}
6 changes: 4 additions & 2 deletions src/samples/i18n-sample/i18n-app/I18nUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import {
VStack,
Divider
} from "@open-pioneer/chakra-integration";
import { ExternalEventService } from "@open-pioneer/integration";
import { useIntl, useService } from "open-pioneer:react-hooks";
import { ReactNode } from "react";
import { SamplePackageComponent } from "i18n-sample-package/SamplePackageComponent";
import { ApplicationContext } from "@open-pioneer/runtime";

export function I18nUI() {
const intl = useIntl();
const appCtx = useService("runtime.ApplicationContext");
const appCtx = useService<ApplicationContext>("runtime.ApplicationContext");
const locale = appCtx.getLocale();
const supportedLocales = appCtx.getSupportedLocales();
const name = "Müller";
Expand Down Expand Up @@ -109,7 +111,7 @@ export function I18nUI() {

function LocalePicker(props: { current: string; locales: readonly string[] }) {
const intl = useIntl();
const eventService = useService("integration.ExternalEventService");
const eventService = useService<ExternalEventService>("integration.ExternalEventService");
const changeLocale = (locale: string | undefined) => {
eventService.emitEvent("locale-changed", {
locale: locale
Expand Down
4 changes: 2 additions & 2 deletions src/samples/properties-sample/properties-app/AppUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "@open-pioneer/chakra-integration";
import { useService } from "open-pioneer:react-hooks";
import { useState } from "react";
import { NotificationLevel } from "./api";
import { NotificationLevel, Notifier } from "./api";
import { NotifierUI } from "./NotifierUI";

export function AppUI() {
Expand All @@ -26,7 +26,7 @@ export function AppUI() {
}

function Form() {
const notifier = useService("properties-app.Notifier");
const notifier = useService<Notifier>("properties-app.Notifier");
const [message, setMessage] = useState("");
const onClick = (level: NotificationLevel) => {
if (!message) {
Expand Down
Loading

0 comments on commit 8e86e0e

Please sign in to comment.