セールスフォースのGraphQLをTypeScriptで扱う
セールスフォースのカスタムオブジェクトをGraphQLを用いて読み書きできるみたいです。
https://developer.salesforce.com/docs/platform/graphql/overview
アクセストークンを取得する
GraphQLのAPIを利用する前にアクセストークンを取得する必要があります。
環境変数のうちusername
とpassword
は自分のログイン情報を使用しています。
type Resp = { access_token: string }
const params = {
grant_type: "password",
client_id: "__環境変数__",
client_secret: "__環境変数__",
username: "__環境変数__",
password: "__環境変数__",
}
const searchParams = new URLSearchParams(params)
const baseURL = `https://xxx.my.salesforce.com/services/oauth2/token`
const input = `${baseURL}?${searchParams.toString()}`
const resp = await fetch(input, { method: "POST" })
const data: Resp = await resp.json()
const result = data.access_token // アクセストークン
設定の方法はこちらで詳しく解説されています。
https://zenn.dev/hid3/articles/c1d19c60a894ce
このアクセストークンは一定時間が経過すると無効になるので、再度取得する必要があります。
GraphQLのスキーマを取得する
アクセストークンを用いてGraphQLのスキーマを取得します。
サイズが大きくそこそこ時間がかかります。弊社の場合は119980行あって、これはHTTPieのようなアプリではIntrospectionが動作せずGraphQLのクエリが補完されませんでした。
const resp = await fetch(
"https://xxx.my.salesforce.com/services/data/v57.0/graphql",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `query IntrospectionQuery {
__schema {
queryType {
name
}
mutationType {
name
}
types {
...FullType
}
directives {
name
description
args {
...InputValue
}
onOperation
onFragment
onField
locations
}
}
}
fragment FullType on __Type {
kind
name
description
fields(includeDeprecated: false) {
name
description
args {
...InputValue
}
type {
...TypeRef
}
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
description
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
description
type {
...TypeRef
}
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
`,
}),
},
)
const json = await resp.json()
Bunを用いてファイルを書き込みます。ファイル名はschema.graphql
とします。
import { buildClientSchema, printSchema } from "graphql"
const json = await resp.json()
// await Bun.write("schema.graphql.json", JSON.stringify(json.data, null, 2))
const schema = buildClientSchema(json.data)
const schemaText = printSchema(schema)
await Bun.write("schema.graphql", schemaText)
適当にファイルを作って実行します。
$ bun create-graphql-schema.ts
json形式のスキーマに関しては使う予定があれば取得します。
Zeusを使ってライブラリを生成する
Zeusというライブラリを用いてGraphQLのスキーマからTypeScriptのライブラリを生成します。
https://github.com/graphql-editor/graphql-zeus
先ほど生成したGraphQLのスキーマを使ってライブラリを生成します。
$ bunx graphql-zeus schema.graphql ./lib --es
この場合はlib
ディレクトリにzeus
というディレクトリが生成されます。
生成されたライブラリを使ってGraphQLのクエリを実行します。
const chain = Chain(
"https://xxx.my.salesforce.com/services/data/v57.0/graphql",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
const query = chain("query")
const resp = await query({
uiapi: {
query: {
Xxx_c: [
{
first: 1
},
{
edges: {
node: {
Id: true
}
}
}
]
}
}
})
ただ自分の場合はこんな感じでエラーになります。
[
{
errorCode: "INVALID_INPUT",
message: "GraphQL input is required for POST.",
}
]
これはこのようにheadersを上書きしていた場合にエラーが発生するみたいです。
const chain = Chain(
"https://xxx.my.salesforce.com/services/data/v57.0/graphql",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
生成されたコードはこのようになっておりheadersが上書きされる仕様になってるみたいです。
return fetch(`${options[0]}`, {
body: JSON.stringify({ query, variables }),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
...fetchOptions,
})
そこでContent-Typeも追加することで解決します。
const chain = Chain(
"https://xxx.my.salesforce.com/services/data/v57.0/graphql",
{
method: "POST",
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
)
レスポンスのerrorsが壊れる
Fetch以降の処理はこのようになっており、もしerrorsが空の配列で返却されるとGraphQLErrorが返されて壊れるみたいです。
return fetch()
.then(handleFetchResponse)
.then((response: GraphQLResponse) => {
if (response.errors) {
throw new GraphQLError(response);
}
return response.data;
});
JavaScriptの世界では空の配列がIF文に使用された場合はTRUEとなります。
if ([]) {
console.log("ERROR")
}
これはセールスフォースのGraphQLのAPIが空のerrorsを返すことが原因と思います。
生成されたコードを書き換えるのは避けたいのでカスタムのクライアントを作ることにしました。
カスタムのクライアントを作る
どうやらThunderという関数を使うとカスタムのクライアントを作ることができるみたいです。
https://graphqleditor.com/docs/tools/zeus/basics/custom-fetch/
import { Thunder } from "@/lib/zeus"
async function customFetch(query: string) {
const accessToken = await createAccessToken()
const response = await fetch(
"https://xxx.my.salesforce.com/services/data/v57.0/graphql",
{
body: JSON.stringify({ query }),
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
)
if (response.ok === false) {
const json = await response.json()
console.error(json)
throw new Error(`Failed to fetch ${response.status}`)
}
const json = await response.json()
if (json.errors.length !== 0) {
for (const error of json.errors) {
throw new Error(error.message)
}
}
return json.data
}
const customClient = Thunder(customFetch)
このようにしておけば少し便利です。
const query = customClient("query")
const mutation = customClient("mutation")
export const client = {
query,
mutation,
}
このように使用できます。
const resp = await client.query({
uiapi: {
query: {
Xxx_c: [
{
first: 1
},
{
edges: {
node: {
Id: true
}
}
}
]
}
}
})
クエリする
クエリのwhereは型が補完されますがObjectになる点に注意してください。
const resp = await client.query({
uiapi: {
query: {
SamplePost__c: [
{
first: 1,
where: {
IsDeleted__c: { eq: false },
},
},
{}
]
}
}
})
値はGraphQLの返り値の殆どで、このように取り出せます。
const nodes = resp.uiapi.query.SamplePost__c?.edges?.map((edge) => {
return edge.node!
})
if (nodes === undefined) {
throw new Error("nodes is undefined")
}
const [node] = nodes
console.log(node)