Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/http-client"
---

Introduces @experimental decorator for annotating generated code as experimental.
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ cspell.yaml
######################
/packages/http-client-js/ @joheredi @timotheeguerin @timovv @qiaozha @MaryGao @xirzec @bterlson @markcowl @allenjzhang @witemple-msft @chrisradek @AlitzelMendez

######################
# Client Framework
######################
/packages/http-client/ @joheredi @timotheeguerin @timovv @qiaozha @MaryGao @xirzec @bterlson @markcowl @allenjzhang @witemple-msft @chrisradek @AlitzelMendez @tadelesh

######################
# IDE
######################
Expand Down
15 changes: 15 additions & 0 deletions packages/http-client/generated-defs/TypeSpec.HttpClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { DecoratorContext, Type } from "@typespec/compiler";

export interface FeatureLifecycleOptions {
readonly emitterScope?: string;
}

export type ExperimentalDecorator = (
context: DecoratorContext,
target: Type,
options?: FeatureLifecycleOptions,
) => void;

export type TypeSpecHttpClientDecorators = {
experimental: ExperimentalDecorator;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// An error in the imports would mean that the decorator is not exported or
// doesn't have the right name.

import { $decorators } from "@typespec/http-client";
import type { TypeSpecHttpClientDecorators } from "./TypeSpec.HttpClient.js";

/**
* An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ...
*/
const _: TypeSpecHttpClientDecorators = $decorators["TypeSpec.HttpClient"];
9 changes: 9 additions & 0 deletions packages/http-client/lib/common.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace TypeSpec.HttpClient;

/**
* Specifies the target emitters that the decorator should apply. If not set, the decorator will be applied to all emitters by default.
* You can use ”!” to exclude specific emitters, for example: `!@typespec/http-client-js, !@typespec/http-client-csharp`
*/
model ClientDecoratorOptions {
emitterScope?: string;
}
10 changes: 10 additions & 0 deletions packages/http-client/lib/decorators.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import "./common.tsp";
import "../dist/src/tsp-index.js";

namespace TypeSpec.HttpClient;

model FeatureLifecycleOptions {
...ClientDecoratorOptions;
}

extern dec experimental(target: unknown, options?: valueof FeatureLifecycleOptions);
4 changes: 4 additions & 0 deletions packages/http-client/lib/main.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import "./decorators.tsp";
import "../dist/src/tsp-index.js";

namespace TypeSpec.HttpClient;
14 changes: 11 additions & 3 deletions packages/http-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
"type": "module",
"main": "dist/src/index.js",
"license": "MIT",
"tspMain": "./lib/main.tsp",
"exports": {
".": {
"import": "./dist/src/index.js"
"typespec": "./lib/main.tsp",
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
},
"./testing": {
"import": "./dist/src/testing/index.js"
"types": "./dist/src/testing/index.d.ts",
"default": "./dist/src/testing/index.js"
},
"./typekit": {
"import": "./dist/src/typekit/index.js"
Expand All @@ -31,17 +35,21 @@
"@alloy-js/rollup-plugin": "^0.1.0",
"@alloy-js/typescript": "^0.19.0",
"@types/node": "~24.1.0",
"@typespec/tspd": "workspace:^",
"@typespec/compiler": "workspace:^",
"@typespec/emitter-framework": "workspace:^",
"@typespec/http": "workspace:^",
"@typespec/library-linter": "workspace:^",
"eslint": "^9.23.0",
"prettier": "~3.6.2",
"typescript": "~5.8.2",
"vitest": "^3.1.2"
},
"scripts": {
"build": "alloy build",
"build": "npm run gen-extern-signature && alloy build && npm run lint-typespec-library",
"lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit",
"clean": "rimraf ./dist",
"gen-extern-signature": "tspd --enable-experimental gen-extern-signature .",
"watch": "alloy build --watch",
"test": "vitest run",
"test:ui": "vitest --ui",
Expand Down
84 changes: 84 additions & 0 deletions packages/http-client/src/decorators/feature-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { createDiagnosticCollector, DiagnosticResult, Program, Type } from "@typespec/compiler";
import { useStateMap } from "@typespec/compiler/utils";
import { ExperimentalDecorator } from "../../generated-defs/TypeSpec.HttpClient.js";
import { createStateSymbol } from "../lib.js";
import { parseScopeFilter, ScopedValue } from "./scope-cache.js";

const featureLifecycleStateSymbol = createStateSymbol("featureLifecycleState");

export type FeatureLifecycleStage = "Experimental";

const [getFeatureLifecycleState, setFeatureLifecycleState] = useStateMap<
Type,
ScopedValue<FeatureLifecycleStage>
>(featureLifecycleStateSymbol);

export const $experimental: ExperimentalDecorator = (context, target, options) => {
const scopeFilter = parseScopeFilter(options?.emitterScope);
if (scopeFilter.excludedEmitters.length > 0 && scopeFilter.includedEmitters.length > 0) {
context.program.reportDiagnostic({
code: "include-and-exclude-scopes",
message: "The @experimental should only either include or exclude scopes, not both.",
severity: "error",
target,
});
}
setFeatureLifecycleState(context.program, target, {
emitterFilter: scopeFilter,
value: "Experimental",
});
};

export interface GetFeatureLifecycleOptions {
emitterName?: string;
}
export function getClientFeatureLifecycle(
program: Program,
target: Type,
options: GetFeatureLifecycleOptions = {},
): DiagnosticResult<FeatureLifecycleStage | undefined> {
const diagnostics = createDiagnosticCollector();

const lifecycle = getFeatureLifecycleState(program, target);

if (!lifecycle) {
return diagnostics.wrap(undefined);
}

const emitterScope = options.emitterName;
const lifecycleValue = lifecycle.value;

if (!lifecycle.emitterFilter.isScoped) {
return diagnostics.wrap(lifecycleValue);
}

// Lifecycle is scoped but no emitter scope is provided to the query function so we return undefined
// this is because we can't determine which emitter the lifecycle is associated with.
if (!emitterScope) {
diagnostics.add({
code: "use-client-context-without-provider",
message: "No emitter scope provided to getClientFeatureLifecycle.",
target,
severity: "warning",
});
return diagnostics.wrap(undefined);
}

if (lifecycle.emitterFilter.includedEmitters.length) {
const value = lifecycle.emitterFilter.includedEmitters.includes(emitterScope)
? lifecycleValue
: undefined;

return diagnostics.wrap(value);
}

if (lifecycle.emitterFilter.excludedEmitters.length) {
const value = lifecycle.emitterFilter.excludedEmitters.includes(emitterScope)
? undefined
: lifecycleValue;

return diagnostics.wrap(value);
}

return diagnostics.wrap(undefined);
}
1 change: 1 addition & 0 deletions packages/http-client/src/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./feature-lifecycle.js";
39 changes: 39 additions & 0 deletions packages/http-client/src/decorators/scope-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface EmitterFilter {
includedEmitters: string[];
excludedEmitters: string[];
isScoped: boolean;
}

export interface ScopedValue<T> {
emitterFilter: EmitterFilter;
value: T;
}

export function parseScopeFilter(string: string | undefined): EmitterFilter {
if (!string) {
return {
excludedEmitters: [],
includedEmitters: [],
isScoped: false,
};
}

const parts = string.split(",");
const includedEmitters: string[] = [];
const excludedEmitters: string[] = [];

for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("!")) {
excludedEmitters.push(trimmed.substring(1));
} else {
includedEmitters.push(trimmed);
}
}

return {
excludedEmitters,
includedEmitters,
isScoped: true,
};
}
2 changes: 2 additions & 0 deletions packages/http-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const namespace = "TypeSpec.HttpClient";
export * from "./context/index.js";
export type * from "./interfaces.js";
export { $lib } from "./lib.js";
export { $decorators } from "./tsp-index.js";
export * from "./utils/index.js";
2 changes: 1 addition & 1 deletion packages/http-client/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ export const $lib = createTypeSpecLibrary({
},
});

export const { reportDiagnostic, createDiagnostic, stateKeys: StateKeys } = $lib;
export const { reportDiagnostic, createDiagnostic, stateKeys: StateKeys, createStateSymbol } = $lib;
14 changes: 8 additions & 6 deletions packages/http-client/src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { resolvePath } from "@typespec/compiler";
import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing";
import { fileURLToPath } from "url";
import {
TypeSpecTestLibrary,
createTestLibrary,
findTestPackageRoot,
} from "@typespec/compiler/testing";

export const TypespecHttpClientLibraryTestLibrary: TypeSpecTestLibrary = createTestLibrary({
name: "@typespec/http-client-library",
packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../../"),
export const HttpClientTestLibrary: TypeSpecTestLibrary = createTestLibrary({
name: "@typespec/http-client",
packageRoot: await findTestPackageRoot(import.meta.url),
});
9 changes: 9 additions & 0 deletions packages/http-client/src/tsp-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { TypeSpecHttpClientDecorators } from "../generated-defs/TypeSpec.HttpClient.js";

import { $experimental } from "./decorators/index.js";

export const $decorators = {
"TypeSpec.HttpClient": {
experimental: $experimental,
} satisfies TypeSpecHttpClientDecorators,
};
18 changes: 17 additions & 1 deletion packages/http-client/src/typekit/kits/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import {
Namespace,
NoTarget,
Operation,
Type,
} from "@typespec/compiler";
import { defineKit } from "@typespec/compiler/typekit";
import { createDiagnosable, defineKit, Diagnosable } from "@typespec/compiler/typekit";
import {
getHttpService,
getServers,
Expand All @@ -17,13 +18,25 @@ import {
resolveAuthentication,
} from "@typespec/http";
import "@typespec/http/experimental/typekit";
import {
getClientFeatureLifecycle,
GetFeatureLifecycleOptions,
} from "../../decorators/feature-lifecycle.js";
import { InternalClient } from "../../interfaces.js";
import { reportDiagnostic } from "../../lib.js";
import { createBaseConstructor, getConstructors } from "../../utils/client-helpers.js";
import { getStringValue } from "../../utils/helpers.js";
import { NameKit } from "./utils.js";

interface ClientKit extends NameKit<InternalClient> {
/**
* Get the feature lifecycle value for a given type
* @param type The type to get the feature lifecycle for
* @param options The options to use when getting the feature lifecycle
*/
getFeatureLifecycle: Diagnosable<
(type: Type, options?: GetFeatureLifecycleOptions) => string | undefined
>;
/**
* Get the parent of a client
* @param type The client to get the parent of
Expand Down Expand Up @@ -98,6 +111,9 @@ export const clientOperationCache = new Map<InternalClient, HttpOperation[]>();

defineKit<TypekitExtension>({
client: {
getFeatureLifecycle: createDiagnosable(function (type, options) {
return getClientFeatureLifecycle(this.program, type, options);
}),
getParent(client) {
const type = client.kind === "Client" ? client.type : client;
if (type.namespace && type.namespace !== this.program.getGlobalNamespaceType()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HttpAuth } from "@typespec/http";
import { authSchemeSymbol, credentialSymbol } from "./credential-symbol.ts";
import { authSchemeSymbol, credentialSymbol } from "./credential-symbol.js";

declare module "@typespec/compiler" {
interface ModelProperty {
Expand Down
Loading
Loading