155 lines
4.4 KiB
TypeScript
155 lines
4.4 KiB
TypeScript
/**
|
|
* Service for fetching OpenAPI specifications from URLs or the filesystem.
|
|
*
|
|
* @since 4.0.0
|
|
*/
|
|
import * as Context from "effect/Context"
|
|
import * as Data from "effect/Data"
|
|
import * as Effect from "effect/Effect"
|
|
import * as FileSystem from "effect/FileSystem"
|
|
import * as Layer from "effect/Layer"
|
|
import * as Match from "effect/Match"
|
|
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
import * as Yaml from "yaml"
|
|
import type { SpecSource } from "./Config.ts"
|
|
|
|
/**
|
|
* Error when fetching a spec fails.
|
|
*
|
|
* **Example** (Creating a spec fetch error)
|
|
*
|
|
* ```ts
|
|
* import * as SpecFetcher from "@effect/ai-codegen/SpecFetcher"
|
|
*
|
|
* const error = new SpecFetcher.SpecFetchError({
|
|
* provider: "openai",
|
|
* source: "https://example.com/openapi.json",
|
|
* cause: new Error("Network error")
|
|
* })
|
|
* ```
|
|
*
|
|
* @category errors
|
|
* @since 4.0.0
|
|
*/
|
|
export class SpecFetchError extends Data.TaggedError("SpecFetchError")<{
|
|
readonly provider: string
|
|
readonly source: string
|
|
readonly cause: unknown
|
|
}> {}
|
|
|
|
/**
|
|
* Service for fetching OpenAPI specifications.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export interface SpecFetcher {
|
|
readonly fetch: (
|
|
source: SpecSource,
|
|
provider: string
|
|
) => Effect.Effect<unknown, SpecFetchError>
|
|
}
|
|
|
|
/**
|
|
* Service tag for fetching OpenAPI specifications from configured sources.
|
|
*
|
|
* @category services
|
|
* @since 4.0.0
|
|
*/
|
|
export const SpecFetcher: Context.Service<SpecFetcher, SpecFetcher> = Context.Service(
|
|
"@effect/ai-codegen/SpecFetcher"
|
|
)
|
|
|
|
/**
|
|
* Layer providing the SpecFetcher service.
|
|
*
|
|
* @category layers
|
|
* @since 4.0.0
|
|
*/
|
|
export const layer: Layer.Layer<
|
|
SpecFetcher,
|
|
never,
|
|
FileSystem.FileSystem | HttpClient.HttpClient
|
|
> = Effect.gen(function*() {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const httpClient = yield* HttpClient.HttpClient
|
|
|
|
const fetchFromFile = Effect.fn("fetchFromFile")(function*(
|
|
path: string,
|
|
provider: string
|
|
) {
|
|
return yield* fs.readFileString(path).pipe(
|
|
Effect.mapError((cause) => new SpecFetchError({ provider, source: path, cause }))
|
|
)
|
|
})
|
|
|
|
const fetchFromUrl = Effect.fn("fetchFromUrl")(function*(
|
|
url: string,
|
|
provider: string
|
|
) {
|
|
return yield* httpClient.get(url).pipe(
|
|
Effect.flatMap((response) => response.text),
|
|
Effect.mapError((cause) => new SpecFetchError({ provider, source: url, cause }))
|
|
)
|
|
})
|
|
|
|
const parseSpec = (content: string, sourceUrl: string, provider: string) => {
|
|
const isYaml = sourceUrl.endsWith(".yaml") || sourceUrl.endsWith(".yml")
|
|
return Effect.try({
|
|
try: () => isYaml ? Yaml.parse(content) : JSON.parse(content),
|
|
catch: (cause) => new SpecFetchError({ provider, source: sourceUrl, cause })
|
|
})
|
|
}
|
|
|
|
const fetchViaStainlessStats = Effect.fn("fetchViaStainlessStats")(function*(
|
|
statsUrl: string,
|
|
provider: string
|
|
) {
|
|
// Fetch stats.yml
|
|
const statsContent = yield* httpClient.get(statsUrl).pipe(
|
|
Effect.flatMap((response) => response.text),
|
|
Effect.mapError((cause) => new SpecFetchError({ provider, source: `stats:${statsUrl}`, cause }))
|
|
)
|
|
|
|
// Parse YAML and extract openapi_spec_url
|
|
const stats = yield* Effect.try({
|
|
try: () => Yaml.parse(statsContent),
|
|
catch: (cause) => new SpecFetchError({ provider, source: `stats:${statsUrl}`, cause })
|
|
})
|
|
|
|
const specUrl = stats?.openapi_spec_url
|
|
if (typeof specUrl !== "string") {
|
|
return yield* new SpecFetchError({
|
|
provider,
|
|
source: `stats:${statsUrl}`,
|
|
cause: new Error("Missing or invalid openapi_spec_url in stats file")
|
|
})
|
|
}
|
|
|
|
// Fetch and parse the actual spec
|
|
const content = yield* fetchFromUrl(specUrl, provider)
|
|
return yield* parseSpec(content, specUrl, provider)
|
|
})
|
|
|
|
const fetch = Effect.fn("fetch")(function*(
|
|
source: SpecSource,
|
|
provider: string
|
|
) {
|
|
// StainlessStats handles its own parsing since the URL is resolved dynamically
|
|
if (source._tag === "StainlessStats") {
|
|
return yield* fetchViaStainlessStats(source.statsUrl, provider)
|
|
}
|
|
|
|
const content = yield* Match.value(source).pipe(
|
|
Match.tag("File", ({ path }) => fetchFromFile(path, provider)),
|
|
Match.tag("Url", ({ url }) => fetchFromUrl(url, provider)),
|
|
Match.exhaustive
|
|
)
|
|
|
|
const sourceString = source._tag === "Url" ? source.url : source.path
|
|
return yield* parseSpec(content, sourceString, provider)
|
|
})
|
|
|
|
return { fetch }
|
|
}).pipe(Layer.effect(SpecFetcher))
|