Skip to content

feat: Azure CosmosDB as a fallback from repos #60

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
AZURE_COSMOSDB_DATABASE=
AZURE_COSMOSDB_ENDPOINT=
AZURE_COSMOSDB_KEY=
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ next-env.d.ts
**/public/sw.js
**/public/worker-*.js
**/public/sw.js.map

bundles
.env

# service worker
sw.js
sw.js.map
workbox-*.js
workbox-*.js.map
76 changes: 56 additions & 20 deletions app/api/graphql/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// import { CosmosContainer } from "@azure-fundamentals/src/graphql/cosmos-client";
// import { QuestionsDataSource, LocalQuestionsDataSource } from "@azure-fundamentals/src/graphql/questionsDataSource";
import {
CombinedQuestionsDataSource,
RepoQuestionsDataSource,
} from "@azure-fundamentals/lib/graphql/questionsDataSource";
import { ApolloServer, BaseContext } from "@apollo/server";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import typeDefs from "@azure-fundamentals/lib/graphql/schemas";
import resolvers from "@azure-fundamentals/lib/graphql/resolvers";
//import { RepoQuestionsDataSource } from "@azure-fundamentals/lib/graphql/questionsDataSource";
//import { FetchQuestions } from "@azure-fundamentals/lib/graphql/repoQuestions";
import { fetchQuestions } from "@azure-fundamentals/lib/graphql/repoQuestions";

interface ContextValue {
dataSources: {
Expand All @@ -19,22 +20,57 @@ const server = new ApolloServer<ContextValue>({
introspection: process.env.NODE_ENV !== "production",
});

//const questions = await FetchQuestions();

const handler = startServerAndCreateNextHandler(
server,
/*{
const handler = startServerAndCreateNextHandler(server, {
context: async () => {
return {
dataSources: {
// questionsDB: process.env.AZURE_COSMOSDB_ENDPOINT
// ? QuestionsDataSource(CosmosContainer())
// : LocalQuestionsDataSource(questions),
questionsDB: RepoQuestionsDataSource(questions),
},
};
if (process.env.AZURE_COSMOSDB_ENDPOINT) {
return {
dataSources: {
questionsDB: CombinedQuestionsDataSource(),
},
};
} else {
// Fallback to GitHub-only data source
return {
dataSources: {
questionsDB: {
getQuestion: async (id: string, link: string) => {
const questions = await fetchQuestions(link);
return questions?.find((q: any) => q.id === id);
},
getQuestions: async (link: string) => {
const questions = await fetchQuestions(link);
return { count: questions?.length || 0 };
},
getRandomQuestions: async (range: number, link: string) => {
const questions = await fetchQuestions(link);
const shuffled = questions?.sort(() => 0.5 - Math.random());
return shuffled?.slice(0, range) || [];
},
},
},
};
}
},
}*/
);
});

// Wrap the handler to handle errors
const wrappedHandler = async (req: Request) => {
try {
return await handler(req);
} catch (error) {
console.error("GraphQL Error:", error);
return new Response(
JSON.stringify({
errors: [{ message: "Internal server error" }],
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
},
},
);
}
};

export { handler as GET, handler as POST };
export { wrappedHandler as GET, wrappedHandler as POST };
13 changes: 7 additions & 6 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { type ReactNode } from "react";
import { type Metadata } from "next";
import { type Metadata, type Viewport } from "next";
import TopNav from "@azure-fundamentals/components/TopNav";
import Footer from "@azure-fundamentals/components/Footer";
import ApolloProvider from "@azure-fundamentals/components/ApolloProvider";
import Cookie from "@azure-fundamentals/components/Cookie";
import "styles/globals.css";

export const viewport: Viewport = {
themeColor: "#3f51b5",
width: "device-width",
initialScale: 1,
};

export const metadata: Metadata = {
appleWebApp: {
capable: true,
Expand Down Expand Up @@ -71,7 +77,6 @@ export const metadata: Metadata = {
follow: true,
index: true,
},
themeColor: "#3f51b5",
title: {
default: "🧪 Practice Exams Platform | Ditectrev",
template: "🧪 Practice Exams Platform | Ditectrev",
Expand All @@ -90,10 +95,6 @@ export const metadata: Metadata = {
site: "@ditectrev",
title: "🧪 Practice Exams Platform | Ditectrev",
},
viewport: {
initialScale: 1,
width: "device-width",
},
};

type RootLayoutProps = {
Expand Down
32 changes: 27 additions & 5 deletions lib/graphql/cosmos-client.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import { CosmosClient } from "@azure/cosmos";

export const CosmosContainer = () => {
export const getDatabase = () => {
const client = new CosmosClient({
endpoint: process.env.AZURE_COSMOSDB_ENDPOINT!,
key: process.env.AZURE_COSMOSDB_KEY!,
});

const container = client
.database(process.env.AZURE_COSMOSDB_DATABASE!)
.container(process.env.AZURE_COSMOSDB_CONTAINER!);
return client.database(process.env.AZURE_COSMOSDB_DATABASE!);
};

export const getQuestionsContainer = async () => {
const database = getDatabase();

return container;
// Try to create container if it doesn't exist
try {
const { container } = await database.containers.createIfNotExists({
id: "questions",
partitionKey: {
paths: ["/examId"],
},
});
return container;
} catch (error: any) {
// If container creation fails, try to get the existing container
if (error.code === 409) {
console.log(
`Container questions already exists, using existing container`,
);
return database.container("questions");
} else {
console.error("Error creating container:", error);
throw error;
}
}
};
153 changes: 151 additions & 2 deletions lib/graphql/questionsDataSource.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/*import { Container } from "@azure/cosmos";
import { Container } from "@azure/cosmos";
import { fetchQuestions } from "./repoQuestions";
import { getQuestionsContainer } from "./cosmos-client";

export const QuestionsDataSource = (container: Container) => {
return {
Expand Down Expand Up @@ -38,7 +40,7 @@ export const QuestionsDataSource = (container: Container) => {
},
};
};
*/

export const RepoQuestionsDataSource = (container: any) => {
return {
async getQuestion(id: string) {
Expand All @@ -65,3 +67,150 @@ export const RepoQuestionsDataSource = (container: any) => {
},
};
};

// Helper function to extract exam ID from URL
const extractExamId = (link: string): string => {
const segments = link.split("/");
return segments[segments.length - 3].replace(/-/g, "_").toLowerCase();
};

export const CombinedQuestionsDataSource = () => {
return {
async getQuestion(id: string, link: string) {
try {
const examId = extractExamId(link);
const questionsContainer = await getQuestionsContainer();

// Try Cosmos DB first (most efficient)
const querySpec = {
query: "SELECT * FROM c WHERE c.id = @id AND c.examId = @examId",
parameters: [
{ name: "@id", value: id },
{ name: "@examId", value: examId },
],
};
const { resources: items } = await questionsContainer.items
.query(querySpec)
.fetchAll();

if (items.length > 0) {
return items[0];
}

// Fallback to GitHub if not found in database
const questions = await fetchQuestions(link);
if (questions) {
const question = questions.find((q: any) => q.id === id);
if (question) {
// Add examId to the question document and upload to database
const questionWithExamId = {
...question,
examId: examId,
};

try {
await questionsContainer.items.upsert(questionWithExamId);
} catch (err) {
console.warn("Failed to upload question to Cosmos DB:", err);
}
return question;
}
}

return null;
} catch (err) {
throw new Error("Error fetching question: " + err);
}
},

async getQuestions(link: string) {
try {
const examId = extractExamId(link);
const questionsContainer = await getQuestionsContainer();

// Try Cosmos DB first
const querySpec = {
query: "SELECT VALUE COUNT(c.id) FROM c WHERE c.examId = @examId",
parameters: [{ name: "@examId", value: examId }],
};
const { resources: items } = await questionsContainer.items
.query(querySpec)
.fetchAll();

if (items[0] > 0) {
return { count: items[0] };
}

// Fallback to GitHub if no questions found in database
const questions = await fetchQuestions(link);
if (questions) {
// Upload all questions to database (only if they don't exist)
try {
for (const question of questions) {
const questionWithExamId = {
...question,
examId: examId,
};
await questionsContainer.items.upsert(questionWithExamId);
}
} catch (err) {
console.warn("Failed to upload questions to Cosmos DB:", err);
}
return { count: questions.length };
}

return { count: 0 };
} catch (err) {
throw new Error("Error fetching questions: " + err);
}
},

async getRandomQuestions(range: number, link: string) {
try {
const examId = extractExamId(link);
const questionsContainer = await getQuestionsContainer();

// Try Cosmos DB first
const querySpec = {
query: "SELECT * FROM c WHERE c.examId = @examId",
parameters: [{ name: "@examId", value: examId }],
};
const { resources: items } = await questionsContainer.items
.query(querySpec)
.fetchAll();

if (items.length > 0) {
// Questions exist in database, return random selection
const shuffled = [...items].sort(() => 0.5 - Math.random());
return shuffled.slice(0, range);
}

// Fallback to GitHub if no questions found in database
const questions = await fetchQuestions(link);
if (questions) {
const shuffled = [...questions].sort(() => 0.5 - Math.random());
const selected = shuffled.slice(0, range);

// Upload selected questions to database (only if they don't exist)
try {
for (const question of selected) {
const questionWithExamId = {
...question,
examId: examId,
};
await questionsContainer.items.upsert(questionWithExamId);
}
} catch (err) {
console.warn("Failed to upload questions to Cosmos DB:", err);
}

return selected;
}

return [];
} catch (err) {
throw new Error("Error fetching random questions: " + err);
}
},
};
};
10 changes: 3 additions & 7 deletions lib/graphql/resolvers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,21 @@ const resolvers = {
{ link }: { link: string },
{ dataSources }: { dataSources: any },
) => {
const response = await fetchQuestions(link);
return { count: response?.length };
return dataSources.questionsDB.getQuestions(link);
},
questionById: async (
_: unknown,
{ id, link }: { id: string; link: string },
{ dataSources }: { dataSources: any },
) => {
const response = await fetchQuestions(link);
return response?.filter((el: any) => el.id === id)[0];
return dataSources.questionsDB.getQuestion(id, link);
},
randomQuestions: async (
_: unknown,
{ range, link }: { range: number; link: string },
{ dataSources }: { dataSources: any },
) => {
const response = await fetchQuestions(link);
const shuffled = response?.sort(() => 0.5 - Math.random());
return shuffled?.slice(0, range);
return dataSources.questionsDB.getRandomQuestions(range, link);
},
},
};
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/schemas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const typeDefs = gql`
question: String
options: [Option]
images: [Images]
examId: String
}
type Questions {
count: Int
Expand Down
Loading
Loading