1/**
2 * Service for fetching OpenAPI specifications from URLs or the filesystem.
3 *
4 * @since 4.0.0
5 */
6import * as Context from "effect/Context"
7import * as Data from "effect/Data"
8import * as Effect from "effect/Effect"
9import * as FileSystem from "effect/FileSystem"
10import * as HttpClient from "effect/http/HttpClient"
11import * as Layer from "effect/Layer"
12import * as Match from "effect/Match"
13import * as Yaml from "yaml"
14import type { SpecSource } from "./Config.ts"
15
16/**
17 * Error when fetching a spec fails.
18 *
19 * **Example** (Creating a spec fetch error)
20 *
21 * ```ts import.meta.vitest
22 * import * as SpecFetcher from "@effect/ai-codegen/SpecFetcher"
23 *
24 * const error = new SpecFetcher.SpecFetchError({
25 * provider: "openai",
26 * source: "https://example.com/openapi.json",
27 * cause: new Error("Network error")
28 * })
29 *
30 * error._tag // => "SpecFetchError"
31 * error.provider // => "openai"
32 * ```
33 *
34 * @category errors
35 * @since 4.0.0
36 */
37export class SpecFetchError extends Data.TaggedError("SpecFetchError")<{
38 readonly provider: string
39 readonly source: string
40 readonly cause: unknown
41}> {}
42
43/**
44 * Service for fetching OpenAPI specifications.
45 *
46 * @category services
47 * @since 4.0.0
48 */
49export interface SpecFetcher {
50 readonly fetch: (
51 source: SpecSource,
52 provider: string
53 ) => Effect.Effect<unknown, SpecFetchError>
54}
55
56/**
57 * Service tag for fetching OpenAPI specifications from configured sources.
58 *
59 * @category services
60 * @since 4.0.0
61 */
62export const SpecFetcher: Context.Service<SpecFetcher, SpecFetcher> = Context.Service(
63 "@effect/ai-codegen/SpecFetcher"
64)
65
66/**
67 * Layer providing the SpecFetcher service.
68 *
69 * @category layers
70 * @since 4.0.0
71 */
72export const layer: Layer.Layer<
73 SpecFetcher,
74 never,
75 FileSystem.FileSystem | HttpClient.HttpClient
76> = Effect.gen(function*() {
77 const fs = yield* FileSystem.FileSystem
78 const httpClient = yield* HttpClient.HttpClient
79
80 const fetchFromFile = Effect.fn("fetchFromFile")(function*(
81 path: string,
82 provider: string
83 ) {
84 return yield* fs.readFileString(path).pipe(
85 Effect.mapError((cause) => new SpecFetchError({ provider, source: path, cause }))
86 )
87 })
88
89 const fetchFromUrl = Effect.fn("fetchFromUrl")(function*(
90 url: string,
91 provider: string
92 ) {
93 return yield* httpClient.get(url).pipe(
94 Effect.flatMap((response) => response.text),
95 Effect.mapError((cause) => new SpecFetchError({ provider, source: url, cause }))
96 )
97 })
98
99 const parseSpec = (content: string, sourceUrl: string, provider: string) => {
100 const isYaml = sourceUrl.endsWith(".yaml") || sourceUrl.endsWith(".yml")
101 return Effect.try({
102 try: () => isYaml ? Yaml.parse(content) : JSON.parse(content),
103 catch: (cause) => new SpecFetchError({ provider, source: sourceUrl, cause })
104 })
105 }
106
107 const fetchViaStainlessStats = Effect.fn("fetchViaStainlessStats")(function*(
108 statsUrl: string,
109 provider: string
110 ) {
111 // Fetch stats.yml
112 const statsContent = yield* httpClient.get(statsUrl).pipe(
113 Effect.flatMap((response) => response.text),
114 Effect.mapError((cause) => new SpecFetchError({ provider, source: `stats:${statsUrl}`, cause }))
115 )
116
117 // Parse YAML and extract openapi_spec_url
118 const stats = yield* Effect.try({
119 try: () => Yaml.parse(statsContent),
120 catch: (cause) => new SpecFetchError({ provider, source: `stats:${statsUrl}`, cause })
121 })
122
123 const specUrl = stats?.openapi_spec_url
124 if (typeof specUrl !== "string") {
125 return yield* new SpecFetchError({
126 provider,
127 source: `stats:${statsUrl}`,
128 cause: new Error("Missing or invalid openapi_spec_url in stats file")
129 })
130 }
131
132 // Fetch and parse the actual spec
133 const content = yield* fetchFromUrl(specUrl, provider)
134 return yield* parseSpec(content, specUrl, provider)
135 })
136
137 const fetch = Effect.fn("fetch")(function*(
138 source: SpecSource,
139 provider: string
140 ) {
141 // StainlessStats handles its own parsing since the URL is resolved dynamically
142 if (source._tag === "StainlessStats") {
143 return yield* fetchViaStainlessStats(source.statsUrl, provider)
144 }
145
146 const content = yield* Match.value(source).pipe(
147 Match.tag("File", ({ path }) => fetchFromFile(path, provider)),
148 Match.tag("Url", ({ url }) => fetchFromUrl(url, provider)),
149 Match.exhaustive
150 )
151
152 const sourceString = source._tag === "Url" ? source.url : source.path
153 return yield* parseSpec(content, sourceString, provider)
154 })
155
156 return { fetch }
157}).pipe(Layer.effect(SpecFetcher))