Skip to content

Commit ea12019

Browse files
committed
codegen braintrust api client from openapi spec
1 parent 4120832 commit ea12019

29 files changed

Lines changed: 2509 additions & 679 deletions

braintrust-api/build.gradle

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Placeholder subproject for the Braintrust OpenAPI-generated client.
2+
//
3+
// The fetchOpenApiSpec task downloads openapi/spec.yaml from the pinned commit
4+
// of https://github.com/braintrustdata/braintrust-openapi into
5+
// build/openapi/spec.yaml, then generateOpenApiSources runs the OpenAPI
6+
// generator (native Java library) to produce sources under
7+
// build/generated/openapi/src/main/java, which are compiled as part of the
8+
// main source set.
9+
//
10+
// Future work will wire this generated client in to replace the hand-rolled
11+
// BraintrustApiClient.HttpImpl.
12+
//
13+
// Pin the ref in gradle.properties:
14+
// braintrustOpenApiRef=<sha-or-branch>
15+
//
16+
// To point at a local checkout instead of fetching:
17+
// BRAINTRUST_OPENAPI_ROOT=/path/to/braintrust-openapi ./gradlew braintrust-api:compileJava
18+
19+
plugins {
20+
id 'java'
21+
id 'org.openapi.generator' version '7.14.0'
22+
}
23+
24+
java {
25+
toolchain {
26+
languageVersion = JavaLanguageVersion.of(17)
27+
}
28+
}
29+
30+
repositories {
31+
mavenCentral()
32+
}
33+
34+
// ── Spec fetch ───────────────────────────────────────────────────────────────
35+
36+
def openApiRef = project.property('braintrustOpenApiRef')
37+
def openApiSpecDir = layout.buildDirectory.dir('openapi')
38+
def openApiSpecFile = openApiSpecDir.map { it.file('spec.yaml') }
39+
40+
tasks.register('fetchOpenApiSpec', Exec) {
41+
description = 'Fetches openapi/spec.yaml from the pinned braintrust-openapi ref.'
42+
group = 'Build'
43+
44+
def localRoot = System.getenv('BRAINTRUST_OPENAPI_ROOT')
45+
46+
inputs.property('openApiRef', openApiRef)
47+
outputs.dir(openApiSpecDir)
48+
49+
def outDir = openApiSpecDir.get().asFile
50+
51+
if (localRoot) {
52+
commandLine 'bash', '-c',
53+
"mkdir -p '${outDir}' && cp '${localRoot}/openapi/spec.yaml' '${outDir}/spec.yaml'"
54+
} else {
55+
commandLine 'bash', "${rootProject.projectDir}/scripts/openapi-fetch.sh",
56+
openApiRef, outDir.absolutePath
57+
}
58+
}
59+
60+
// ── Code generation ──────────────────────────────────────────────────────────
61+
62+
def generatedSourcesDir = layout.buildDirectory.dir('generated/openapi')
63+
64+
65+
openApiGenerate {
66+
generatorName = 'java'
67+
library = 'native'
68+
69+
// Custom templates that fix a generator bug where anyOf schemas with
70+
// generic container variants (List<Foo>, Map<K,V>) produce invalid Java
71+
// (e.g. `List<Foo>.class`, `getList<Foo>()`). See anyof_model.mustache.
72+
templateDir = "${projectDir}/src/main/resources/templates/Java"
73+
74+
inputSpec = openApiSpecFile.map { it.asFile.absolutePath }
75+
76+
outputDir = generatedSourcesDir.map { it.asFile.absolutePath }
77+
78+
apiPackage = 'dev.braintrust.openapi.api'
79+
modelPackage = 'dev.braintrust.openapi.model'
80+
invokerPackage = 'dev.braintrust.openapi'
81+
82+
configOptions = [
83+
// Use javax (not jakarta) — we're on Java 17 without EE migration
84+
useJakartaEe : 'false',
85+
// Dates as java.time types
86+
dateLibrary : 'java8',
87+
// Bearer token auth — SDK users supply a Braintrust API key
88+
useRuntimeException : 'true',
89+
serializationLibrary : 'jackson',
90+
91+
// Skip boilerplate we don't need yet
92+
generateApiTests : 'false',
93+
generateModelTests : 'false',
94+
generateApiDocumentation : 'false',
95+
generateModelDocumentation : 'false',
96+
]
97+
98+
// Inline schemas whose titles collide with top-level component schema names, causing the
99+
// generator to overwrite the real full POJO with a discriminator stub.
100+
// checkClobberedSchemas detects regressions; add new entries here when it fails.
101+
inlineSchemaNameMappings = [
102+
'prompt' : 'FunctionDataPrompt',
103+
'global' : 'FunctionDataGlobal',
104+
'code' : 'FunctionDataCode',
105+
'remote_eval' : 'FunctionDataRemoteEval',
106+
'parameters' : 'FunctionDataParameters',
107+
'function' : 'InlineFunctionRef',
108+
'function_1' : 'InlineFunction1',
109+
'function_2' : 'InlineFunction2',
110+
'function_3' : 'InlineFunction3',
111+
'experiment' : 'CodeBundleLocationExperiment',
112+
'user' : 'ChatMessageUser',
113+
]
114+
115+
// Don't generate a full Maven project skeleton — just the sources
116+
globalProperties = [
117+
apis : '',
118+
models : '',
119+
supportingFiles : '',
120+
apiTests : 'false',
121+
modelTests : 'false',
122+
apiDocs : 'false',
123+
modelDocs : 'false',
124+
]
125+
126+
}
127+
128+
// ── Clobber detection ────────────────────────────────────────────────────────
129+
//
130+
// The generator names inline anyOf/oneOf variant classes after their `title`
131+
// field, converting it to PascalCase. When that derived name matches an existing
132+
// components/schemas entry the real POJO gets silently overwritten with a stub.
133+
//
134+
// This task detects collisions purely from the spec — no generated code needed.
135+
// It runs before fetchOpenApiSpec (and thus before generation) so it fails fast.
136+
137+
tasks.register('checkClobberedSchemas') {
138+
description = 'Fails if any inline schema title would clobber a component schema name.'
139+
group = 'Verification'
140+
141+
def specFile = openApiSpecFile
142+
// Capture the current mappings at configuration time so they're available at execution time.
143+
// inlineSchemaNameMappings is a Gradle MapProperty so .get() is needed to resolve it.
144+
def mappedTitles = openApiGenerate.inlineSchemaNameMappings.get().keySet() as Set
145+
inputs.file(specFile)
146+
147+
doLast {
148+
if (!specFile.get().asFile.exists()) {
149+
logger.lifecycle("checkClobberedSchemas: skipped (spec not yet fetched)")
150+
return
151+
}
152+
153+
def yaml = new org.yaml.snakeyaml.Yaml()
154+
def spec = yaml.load(specFile.get().asFile.text)
155+
// Only protect component schemas that are plain POJOs (have `properties`).
156+
// anyOf/oneOf wrapper schemas can't be "clobbered" in the same way — their
157+
// inline variants are what create the class, not a separate definition.
158+
def componentNames = (spec?.components?.schemas?.findAll { name, schema ->
159+
schema?.containsKey('properties')
160+
}?.keySet() ?: []) as Set
161+
162+
// Convert a title to the PascalCase class name the generator would use.
163+
def toPascalCase = { String s ->
164+
s.split('[_\\-\\s]+').collect { it.capitalize() }.join('')
165+
}
166+
167+
// Collect all inline schema titles from anywhere in the spec.
168+
def inlineTitles = [:] // title -> example location path
169+
def collectTitles
170+
collectTitles = { node, path ->
171+
if (node instanceof Map) {
172+
def title = node.get('title')
173+
// Flag titled inline schemas — those nested inside a component schema
174+
// definition (path depth > 2 within components.schemas). Top-level
175+
// component schemas are at exactly components.schemas.<Name>, which we
176+
// skip since those are the schemas we're protecting, not the inliners.
177+
def isTopLevelComponent = path ==~ /components\.schemas\.[^.]+/
178+
if (title instanceof String && !title.isEmpty() && !isTopLevelComponent
179+
&& (node.containsKey('type') || node.containsKey('properties')
180+
|| node.containsKey('anyOf') || node.containsKey('oneOf'))) {
181+
inlineTitles.putIfAbsent(title, path)
182+
}
183+
node.each { k, v -> collectTitles(v, path ? "${path}.${k}" : k) }
184+
} else if (node instanceof List) {
185+
node.eachWithIndex { v, i -> collectTitles(v, "${path}[${i}]") }
186+
}
187+
}
188+
collectTitles(spec, '')
189+
190+
// Find titles that would collide with a component schema name, ignoring
191+
// any that are already handled by inlineSchemaNameMappings.
192+
def collisions = inlineTitles.findAll { entry ->
193+
def pascal = toPascalCase(entry.key)
194+
componentNames.contains(pascal) && !mappedTitles.contains(entry.key)
195+
}
196+
197+
if (!collisions.isEmpty()) {
198+
def msg = new StringBuilder()
199+
msg << '\n\nERROR: The following inline schema titles would clobber component schema\n'
200+
msg << 'POJOs of the same name. Add them to inlineSchemaNameMappings in\n'
201+
msg << 'braintrust-api/build.gradle:\n\n'
202+
collisions.sort { it.key }.each { entry ->
203+
def pascal = toPascalCase(entry.key)
204+
msg << " '${entry.key}' : 'Inline${pascal}', // found at: ${entry.value}\n"
205+
}
206+
throw new GradleException(msg.toString())
207+
}
208+
209+
logger.lifecycle("checkClobberedSchemas: OK (${inlineTitles.size()} inline titles checked against ${componentNames.size()} component schemas)")
210+
}
211+
}
212+
213+
// Wire fetch → check → generate → compile
214+
// checkClobberedSchemas runs after fetch (needs the spec) but before generation
215+
// so it fails fast without wasting time on codegen.
216+
tasks.named('checkClobberedSchemas') {
217+
dependsOn tasks.named('fetchOpenApiSpec')
218+
}
219+
220+
tasks.named('openApiGenerate') {
221+
dependsOn tasks.named('checkClobberedSchemas')
222+
}
223+
224+
tasks.named('compileJava') {
225+
dependsOn tasks.named('openApiGenerate')
226+
}
227+
228+
// Add the generated sources to the main source set
229+
sourceSets {
230+
main {
231+
java {
232+
srcDir generatedSourcesDir.map { it.dir('src/main/java') }
233+
}
234+
}
235+
}
236+
237+
// ── Dependencies ─────────────────────────────────────────────────────────────
238+
239+
dependencies {
240+
// Required by the openapi-generator native Java library
241+
implementation "com.fasterxml.jackson.core:jackson-databind:${rootProject.ext.jacksonVersion}"
242+
implementation "com.fasterxml.jackson.core:jackson-annotations:${rootProject.ext.jacksonVersion}"
243+
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${rootProject.ext.jacksonVersion}"
244+
implementation 'org.openapitools:jackson-databind-nullable:0.2.10'
245+
246+
// TODO: can we use the other nullable annotations here? The ones used by the sdk?
247+
// @Nullable / @javax.annotation annotations used by generated code
248+
compileOnly 'com.google.code.findbugs:jsr305:3.0.2'
249+
compileOnly 'javax.annotation:javax.annotation-api:1.3.2'
250+
251+
testImplementation "org.junit.jupiter:junit-jupiter-api:${rootProject.ext.junitVersion}"
252+
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${rootProject.ext.junitVersion}"
253+
}

0 commit comments

Comments
 (0)