Skip to content

Commit

Permalink
[HTTP] adds getStreamMetadata API (#5153)
Browse files Browse the repository at this point in the history
Implements #4957

Link to design gist:
https://gist.github.com/chrisradek/8b160bae56ceb1bf925933e1a2bb73c7

Design reviewed with TCGC team, in draft until I confirm this is the API
we want to use for emitters.

---------

Co-authored-by: Christopher Radek <[email protected]>
Co-authored-by: Mark Cowlishaw <[email protected]>
  • Loading branch information
3 people authored Jan 7, 2025
1 parent b7e35b6 commit 930ac13
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .chronus/changes/get-stream-metadata-2024-10-19-16-52-8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/http"
---

Adds getStreamMetadata JS API to simplify getting stream metadata from operation parameters and responses.
4 changes: 4 additions & 0 deletions packages/http/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"typespec": "./lib/streams/main.tsp",
"types": "./dist/src/streams/index.d.ts",
"default": "./dist/src/streams/index.js"
},
"./experimental": {
"types": "./dist/src/experimental/index.d.ts",
"default": "./dist/src/experimental/index.js"
}
},
"engines": {
Expand Down
1 change: 1 addition & 0 deletions packages/http/src/experimental/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StreamMetadata, getStreamMetadata } from "./streams.js";
81 changes: 81 additions & 0 deletions packages/http/src/experimental/streams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Model, ModelProperty, Program, Type } from "@typespec/compiler";
import { HttpOperationParameters, HttpOperationResponseContent } from "../types.js";
let getStreamOf: typeof import("@typespec/streams").getStreamOf;
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
getStreamOf = (await import("@typespec/streams")).getStreamOf;
} catch {
getStreamOf = () => {
throw new Error("@typespec/streams was not found");
};
}

export interface StreamMetadata {
/**
* The `Type` of the property decorated with `@body`.
*/
bodyType: Type;
/**
* The `Type` of the stream model.
* For example, an instance of `HttpStream`.
*/
originalType: Type;
/**
* The `Type` of the streaming payload.
*
* For example, given `HttpStream<Foo, "application/jsonl">`,
* the `streamType` would be `Foo`.
*/
streamType: Type;
/**
* The list of content-types that this stream supports.
*/
contentTypes: string[];
}

/**
* Gets stream metadata for a given `HttpOperationParameters` or `HttpOperationResponseContent`.
*/
export function getStreamMetadata(
program: Program,
httpParametersOrResponse: HttpOperationParameters | HttpOperationResponseContent,
): StreamMetadata | undefined {
const body = httpParametersOrResponse.body;
if (!body) return;

const contentTypes = body.contentTypes;
if (!contentTypes.length) return;

// @body is always explicitly set by HttpStream, so body.property will be defined.
const bodyProperty = body.property;
if (!bodyProperty) return;

const streamData = getStreamFromBodyProperty(program, bodyProperty);
if (!streamData) return;

return {
bodyType: body.type,
originalType: streamData.model,
streamType: streamData.streamOf,
contentTypes: contentTypes,
};
}

function getStreamFromBodyProperty(
program: Program,
bodyProperty: ModelProperty,
): { model: Model; streamOf: Type } | undefined {
// Check the model first, then if we can't find it, fallback to the sourceProperty model.
const streamOf = bodyProperty.model ? getStreamOf(program, bodyProperty.model) : undefined;

if (streamOf) {
// if `streamOf` is defined, then we know that `bodyProperty.model` is defined.
return { model: bodyProperty.model!, streamOf };
}

if (bodyProperty.sourceProperty) {
return getStreamFromBodyProperty(program, bodyProperty.sourceProperty);
}
return;
}
196 changes: 196 additions & 0 deletions packages/http/test/streams/get-stream-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Model, Program } from "@typespec/compiler";
import {
createTestHost,
createTestWrapper,
expectDiagnosticEmpty,
type BasicTestRunner,
} from "@typespec/compiler/testing";
import { StreamsTestLibrary } from "@typespec/streams/testing";
import { assert, beforeEach, describe, expect, it } from "vitest";
import { getStreamMetadata } from "../../src/experimental/index.js";
import { getAllHttpServices } from "../../src/operations.js";
import { HttpTestLibrary } from "../../src/testing/index.js";
import { HttpService } from "../../src/types.js";

let runner: BasicTestRunner;
let getHttpServiceWithProgram: (
code: string,
) => Promise<{ service: HttpService; Thing: Model; program: Program }>;

beforeEach(async () => {
const host = await createTestHost({
libraries: [StreamsTestLibrary, HttpTestLibrary],
});
runner = createTestWrapper(host, {
autoImports: [`@typespec/http/streams`, "@typespec/streams"],
autoUsings: ["TypeSpec.Http", "TypeSpec.Http.Streams", "TypeSpec.Streams"],
});
getHttpServiceWithProgram = async (code) => {
const { Thing } = await runner.compile(`
@test model Thing { id: string }
${code}
`);
assert(Thing.kind === "Model");
const [services, diagnostics] = getAllHttpServices(runner.program);

expectDiagnosticEmpty(diagnostics);
return { service: services[0], Thing, program: runner.program };
};
});

describe("Operation Responses", () => {
it("can get stream metadata from HttpStream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(): HttpStream<Thing, "application/jsonl", string>;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.responses[0].responses[0]);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(streamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "HttpStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from JsonlStream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(): JsonlStream<Thing>;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.responses[0].responses[0]);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(streamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "JsonlStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from custom base Stream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@streamOf(Thing)
model CustomStream {
@header contentType: "custom/built-here";
@body body: bytes;
}
@route("/")
op get(): CustomStream;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.responses[0].responses[0]);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "bytes" });
expect(streamMetadata?.contentTypes).toEqual(["custom/built-here"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "CustomStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from intersection with stream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(): JsonlStream<Thing> & { @statusCode statusCode: 204; };
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.responses[0].responses[0]);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(streamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "JsonlStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from each HttpResponseContent", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(): JsonlStream<Thing> | HttpStream<Thing, "custom/json", string>;
`);

const operation = service.operations[0];

const jsonlStreamMetadata = getStreamMetadata(program, operation.responses[0].responses[0]);
expect(jsonlStreamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(jsonlStreamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(jsonlStreamMetadata?.originalType).toMatchObject({ name: "JsonlStream" });
expect(jsonlStreamMetadata?.streamType).toBe(Thing);

const httpStreamMetadata = getStreamMetadata(program, operation.responses[0].responses[1]);
expect(httpStreamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(httpStreamMetadata?.contentTypes).toEqual(["custom/json"]);
expect(httpStreamMetadata?.originalType).toMatchObject({ name: "HttpStream" });
expect(httpStreamMetadata?.streamType).toBe(Thing);
});
});

describe("Operation Parameters", () => {
it("can get stream metadata from HttpStream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(stream: HttpStream<Thing, "application/jsonl", string>): void;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.parameters);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(streamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "HttpStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from JsonlStream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(stream: JsonlStream<Thing>): void;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.parameters);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(streamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "JsonlStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from custom base Stream", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@streamOf(Thing)
model CustomStream {
@header contentType: "custom/built-here";
@body body: bytes;
}
@route("/")
op get(stream: CustomStream): void;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.parameters);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "bytes" });
expect(streamMetadata?.contentTypes).toEqual(["custom/built-here"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "CustomStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});

it("can get stream metadata from spread parameters", async () => {
const { service, Thing, program } = await getHttpServiceWithProgram(`
@route("/")
op get(...JsonlStream<Thing>): void;
`);

const operation = service.operations[0];
const streamMetadata = getStreamMetadata(program, operation.parameters);

expect(streamMetadata?.bodyType).toMatchObject({ kind: "Scalar", name: "string" });
expect(streamMetadata?.contentTypes).toEqual(["application/jsonl"]);
expect(streamMetadata?.originalType).toMatchObject({ name: "JsonlStream" });
expect(streamMetadata?.streamType).toBe(Thing);
});
});

0 comments on commit 930ac13

Please sign in to comment.